From d162f4a935d12f073ef6f287836c7144793a5450 Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Sun, 26 Jul 2026 18:14:43 +0800 Subject: [PATCH 1/7] feat: Add automatic application of recommended game settings - Introduced a new feature to automatically apply recommended game settings before launching the game, including frame rate, story, large operations, prompts, and confirmation settings. - Implemented localization for the new feature in Simplified Chinese (zh-CN), Miao (zh-MIAO), and Traditional Chinese (zh-TW). - Created a new module `player_prefs.py` to handle the safe update of local PlayerPrefs settings for the game. - Enhanced the `Device` class to integrate the new game settings application logic during app start and stop processes. - Added comprehensive unit tests for error handling and PlayerPrefs management to ensure robustness and reliability. - Updated the logger to allow for more flexible error context logging without always including traceback. --- alas.py | 2 + config/template.json | 1 + module/config/argument/args.json | 8 + module/config/argument/argument.yaml | 4 + module/config/config_generated.py | 1 + module/config/i18n/en-US.json | 6 + module/config/i18n/ja-JP.json | 6 + module/config/i18n/zh-CN.json | 6 + module/config/i18n/zh-MIAO.json | 6 + module/config/i18n/zh-TW.json | 6 + module/device/device.py | 8 + module/game_setting/player_prefs.py | 663 ++++++++++++++++++++++++ module/logger.py | 11 +- tests/test_alas_error_handling.py | 55 ++ tests/test_game_setting_player_prefs.py | 281 ++++++++++ 15 files changed, 1061 insertions(+), 3 deletions(-) create mode 100644 module/game_setting/player_prefs.py create mode 100644 tests/test_alas_error_handling.py create mode 100644 tests/test_game_setting_player_prefs.py diff --git a/alas.py b/alas.py index b1edbbe9c..0db16ab30 100644 --- a/alas.py +++ b/alas.py @@ -311,6 +311,8 @@ def run(self, command, skip_first_screenshot=False): action='通常无需处理;若反复发生,请检查游戏包名、模拟器状态和登录流程。', exc=e, level=30, + # 预期恢复路径仅保留异常摘要,避免堆栈淹没后续重启日志。 + with_traceback=False, ) self._check_sensitive_exit(command, e) handle_notify( diff --git a/config/template.json b/config/template.json index ef706265c..b5b75fedd 100644 --- a/config/template.json +++ b/config/template.json @@ -74,6 +74,7 @@ "ServerName": "disabled", "ScreenshotMethod": "auto", "ControlMethod": "MaaTouch", + "GameSettings": false, "ScreenshotDedithering": false, "AdbRestart": false }, diff --git a/module/config/argument/args.json b/module/config/argument/args.json index ca96e2b34..759cac390 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -353,6 +353,14 @@ "MaaTouch" ] }, + "GameSettings": { + "type": "checkbox", + "value": false, + "option": [ + true, + false + ] + }, "ScreenshotDedithering": { "type": "checkbox", "value": false diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 792b29979..2fcf78a6e 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -116,6 +116,10 @@ Emulator: ControlMethod: value: MaaTouch option: [ADB, uiautomator2, minitouch, Hermit, MaaTouch] + GameSettings: + type: checkbox + value: false + option: [true, false] ScreenshotDedithering: false AdbRestart: false EmulatorInfo: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 1b3335b70..9f7bea819 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -94,6 +94,7 @@ class GeneratedConfig: Emulator_ServerName = 'disabled' # disabled, cn_android-0, cn_android-1, cn_android-2, cn_android-3, cn_android-4, cn_android-5, cn_android-6, cn_android-7, cn_android-8, cn_android-9, cn_android-10, cn_android-11, cn_android-12, cn_android-13, cn_android-14, cn_android-15, cn_android-16, cn_android-17, cn_android-18, cn_android-19, cn_android-20, cn_android-21, cn_android-22, cn_android-23, cn_android-24, cn_android-25, cn_android-26, cn_android-27, cn_android-28, cn_android-29, cn_ios-0, cn_ios-1, cn_ios-2, cn_ios-3, cn_ios-4, cn_ios-5, cn_ios-6, cn_ios-7, cn_ios-8, cn_ios-9, cn_ios-10, cn_channel-0, cn_channel-1, cn_channel-2, cn_channel-3, cn_channel-4, cn_channel-5, en-0, en-1, en-2, en-3, en-4, en-5, jp-0, jp-1, jp-2, jp-3, jp-4, jp-5, jp-6, jp-7, jp-8, jp-9, jp-10, jp-11, jp-12, jp-13, jp-14, jp-15, jp-16, jp-17 Emulator_ScreenshotMethod = 'auto' # auto, ADB, ADB_nc, uiautomator2, aScreenCap, aScreenCap_nc, DroidCast, DroidCast_raw, nemu_ipc, ldopengl Emulator_ControlMethod = 'MaaTouch' # ADB, uiautomator2, minitouch, Hermit, MaaTouch + Emulator_GameSettings = False # True, False Emulator_ScreenshotDedithering = False Emulator_AdbRestart = False diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 3e2b61c57..c59fc4682 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -597,6 +597,12 @@ "Hermit": "Hermit", "MaaTouch": "MaaTouch" }, + "GameSettings": { + "name": "Automatically apply recommended game settings", + "help": "Before starting the game, automatically attempt to apply the recommended frame-rate, story, Operation Siren, notification, and confirmation settings. It runs only when the game process has fully stopped, root or su access is available, and the write passes verification. Settings data is processed in memory only; no backup or recovery file is created. If it cannot run safely, it is skipped without affecting normal game startup.", + "True": "Enabled", + "False": "Disabled" + }, "ScreenshotDedithering": { "name": "Remove Image Color Dithering", "help": "Enable when running on a real device" diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index cafa475a0..d8bc61191 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -597,6 +597,12 @@ "Hermit": "Hermit", "MaaTouch": "MaaTouch" }, + "GameSettings": { + "name": "推奨ゲーム設定を自動適用", + "help": "ゲームの起動前に、推奨するフレームレート、ストーリー、大型作戦、通知および確認に関する設定を自動的に適用します。ゲームプロセスが完全に停止し、root または su 権限が利用可能で、書き込みの検証に成功した場合にのみ実行します。設定データはメモリ内でのみ処理され、バックアップや復元用のファイルは作成しません。安全に実行できない場合はスキップし、通常のゲーム起動には影響しません。", + "True": "有効", + "False": "無効" + }, "ScreenshotDedithering": { "name": "画像色ディザリング除去", "help": "実機で実行する場合に有効にしてください" diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 13ac8a07c..61c70d01f 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -597,6 +597,12 @@ "Hermit": "Hermit", "MaaTouch": "MaaTouch" }, + "GameSettings": { + "name": "自动应用推荐游戏设置", + "help": "在启动游戏前,自动尝试应用推荐的帧率、剧情、大型作战、提示及确认设置。仅在游戏进程已完全停止、具备 root 或 su 权限且写入校验通过时执行。设置数据仅在内存中处理,不创建备份或恢复文件;无法安全执行时将跳过,不影响游戏正常启动。", + "True": "开启", + "False": "关闭" + }, "ScreenshotDedithering": { "name": "去除图片色彩抖动", "help": "在手机上运行时开启" diff --git a/module/config/i18n/zh-MIAO.json b/module/config/i18n/zh-MIAO.json index e689c2d2c..fa9c19ca2 100644 --- a/module/config/i18n/zh-MIAO.json +++ b/module/config/i18n/zh-MIAO.json @@ -597,6 +597,12 @@ "Hermit": "Hermit喵", "MaaTouch": "MaaTouch喵" }, + "GameSettings": { + "name": "自动应用推荐游戏设置", + "help": "在启动游戏前,自动尝试应用推荐的帧率、剧情、大型作战、提示及确认设置。仅在游戏进程已完全停止、具备 root 或 su 权限且写入校验通过时执行。设置数据仅在内存中处理,不创建备份或恢复文件;无法安全执行时将跳过,不影响游戏正常启动。", + "True": "开启", + "False": "关闭" + }, "ScreenshotDedithering": { "name": "去除图片色彩喵", "help": "手机上运行时开启喵~" diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 5d4c3a997..f2e0ead6f 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -597,6 +597,12 @@ "Hermit": "Hermit", "MaaTouch": "MaaTouch" }, + "GameSettings": { + "name": "自動套用建議的遊戲設定", + "help": "在啟動遊戲前,自動嘗試套用建議的幀率、劇情、大型作戰、提示及確認設定。僅在遊戲程序已完全停止、具備 root 或 su 權限且寫入驗證通過時執行。設定資料僅於記憶體中處理,不會建立備份或還原檔案;無法安全執行時會略過,不影響遊戲正常啟動。", + "True": "開啟", + "False": "關閉" + }, "ScreenshotDedithering": { "name": "去除圖片色彩抖動", "help": "在手機上執行時開啟" diff --git a/module/device/device.py b/module/device/device.py index 25e027109..cdd6e87c9 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -451,6 +451,12 @@ def app_start(self): logger.critical('[Device] 错误 没有启动/停止应用,因为 HandleError 已禁用') logger.critical('[Device] 请启用 Alas.Error.HandleError 或手动登录碧蓝航线') raise RequestHumanTakeover + if getattr(self.config, 'Emulator_GameSettings', False): + from module.game_setting.player_prefs import apply_recommended_game_settings + + wait_for_stop = getattr(self, '_game_settings_wait_for_stop', False) + apply_recommended_game_settings(self, wait_for_stop=wait_for_stop) + self._game_settings_wait_for_stop = False super().app_start() self.stuck_record_clear() self.click_record_clear() @@ -461,5 +467,7 @@ def app_stop(self): logger.critical('[Device] 请启用 Alas.Error.HandleError 或手动登录碧蓝航线') raise RequestHumanTakeover super().app_stop() + if getattr(self.config, 'Emulator_GameSettings', False): + self._game_settings_wait_for_stop = True self.stuck_record_clear() self.click_record_clear() diff --git a/module/game_setting/player_prefs.py b/module/game_setting/player_prefs.py new file mode 100644 index 000000000..813c84d36 --- /dev/null +++ b/module/game_setting/player_prefs.py @@ -0,0 +1,663 @@ +"""在游戏启动前安全地更新碧蓝航线的本地 PlayerPrefs 设置。""" + +import hashlib +import os +import re +import secrets +import shlex +import subprocess +import time +import xml.etree.ElementTree as etree +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from module.exception import RequestHumanTakeover +from module.logger import logger + + +PACKAGE_PATTERN = re.compile(r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$') +PREFS_FILE_PATTERN = re.compile(r'^[A-Za-z0-9_.-]+\.v2\.playerprefs\.xml$') +STANDBY_MODE_KEY_PATTERN = re.compile(r'^STANDBY_MODE_KEY_[0-9]+$') +STORY_SPEED_KEY_PATTERN = re.compile(r'^story_speed_flag[0-9]+$') +METADATA_PATTERN = re.compile(r'^(?P[0-9]+):(?P[0-9]+):(?P[0-7]{3,4})$') +SELINUX_CONTEXT_PATTERN = re.compile(r'^[A-Za-z0-9_:,.-]+$') +LEGACY_TRANSACTION_SUFFIX_PATTERN = re.compile(r'^\.alas-\d{8}-\d{6}-[0-9a-f]{12}\.bak$') +LEGACY_TEMPORARY_TRANSACTION_SUFFIX_PATTERN = re.compile( + r'^\.alas-\d{8}-\d{6}-[0-9a-f]{12}\.(?:tmp|rollback\.tmp)$' +) +TEMPORARY_TRANSACTION_SUFFIX_PATTERN = re.compile(r'^\.alas-tmp-[0-9a-f]{16}\.tmp$') +STORY_SPEED_VALUE = 9 + +# 仅维护经过当前五服 Lua 源码验证的设置。不要根据 setting_generated.py 泛化写入。 +RECOMMENDED_INT_SETTINGS = { + 'fps_limit': 60, + 'world_flag_story_tips': 1, + 'world_flag_consume_item': 1, + 'world_flag_auto_save_area': 0, + 'story_autoplay_flag': 1, + 'display_ship_get_effect': 0, + 'QUICK_CHANGE_EQUIP': 0, + 'BATTLERESULT_DISPAY_PAINTING': 0, + 'world_sub_auto_call': 0, +} +RECOMMENDED_STRING_SETTINGS = { + '_WorldBossProgressTipFlag_': '', +} + + +class PlayerPrefsError(Exception): + """PlayerPrefs 事务的基础异常。""" + + +class PlayerPrefsUnsupported(PlayerPrefsError): + """当前设备或文件格式不支持安全写入。""" + + +class PlayerPrefsWriteError(PlayerPrefsError): + """写入或写后校验失败。""" + + +@dataclass(frozen=True) +class PlayerPrefsChanges: + """一次 XML 更新的变更摘要。""" + + static_changed: int + story_speed_changed: int + standby_changed: int + story_speed_keys: tuple[str, ...] + standby_keys: tuple[str, ...] + + @property + def changed(self) -> bool: + return ( + self.static_changed > 0 + or self.story_speed_changed > 0 + or self.standby_changed > 0 + ) + + +@dataclass(frozen=True) +class PlayerPrefsMetadata: + """Android 应用私有文件的所有者、权限和 SELinux 上下文。""" + + uid: str + gid: str + mode: str + context: str + + +@dataclass(frozen=True) +class AdbResult: + """已执行的 ADB 命令结果。""" + + returncode: int + stdout: str + stderr: str + + +def _is_target_key(name: str | None) -> bool: + """判断键是否在严格维护的静态或动态设置白名单内。""" + return isinstance(name, str) and ( + name in RECOMMENDED_INT_SETTINGS + or name in RECOMMENDED_STRING_SETTINGS + or STORY_SPEED_KEY_PATTERN.fullmatch(name) is not None + or STANDBY_MODE_KEY_PATTERN.fullmatch(name) is not None + ) + + +def _index_target_entries(root: etree.Element) -> dict[str, etree.Element]: + """索引需要读取或修改的设置项,并拒绝同名目标键。""" + if root.tag != 'map': + raise PlayerPrefsUnsupported(f'不支持的 PlayerPrefs 根节点: {root.tag!r}') + + entries = {} + for element in root: + name = element.get('name') + if not _is_target_key(name): + continue + if name in entries: + raise PlayerPrefsUnsupported(f'PlayerPrefs 中存在重复的目标键: {name!r}') + entries[name] = element + return entries + + +def _set_int(root: etree.Element, entries: dict[str, etree.Element], name: str, value: int) -> bool: + """以 Android SharedPreferences 的 int 格式写入单个白名单键。""" + expected = str(value) + element = entries.get(name) + if element is None: + element = etree.Element('int', {'name': name, 'value': expected}) + root.append(element) + entries[name] = element + return True + + if element.tag != 'int': + raise PlayerPrefsUnsupported(f'目标键 {name!r} 的 XML 类型不是 int: {element.tag!r}') + if element.text and element.text.strip(): + raise PlayerPrefsUnsupported(f'目标键 {name!r} 包含无法安全处理的文本值') + if element.get('value') == expected: + return False + + element.set('value', expected) + return True + + +def _set_string(root: etree.Element, entries: dict[str, etree.Element], name: str, value: str) -> bool: + """以 Android SharedPreferences 的 string 格式写入单个白名单键。""" + element = entries.get(name) + if element is None: + element = etree.Element('string', {'name': name}) + element.text = value + root.append(element) + entries[name] = element + return True + + if element.tag != 'string': + raise PlayerPrefsUnsupported(f'目标键 {name!r} 的 XML 类型不是 string: {element.tag!r}') + if element.get('value') is not None or len(element): + raise PlayerPrefsUnsupported(f'目标键 {name!r} 的 XML 内容无法安全处理') + current = '' if element.text is None else element.text + if current == value: + return False + + element.text = value + return True + + +def _serialize_xml(root: etree.Element) -> bytes: + """生成 Android 可读取的 UTF-8 SharedPreferences XML。""" + etree.indent(root, space=' ') + return etree.tostring(root, encoding='utf-8', xml_declaration=True, short_empty_elements=True) + + +def update_player_prefs_xml(content: bytes) -> tuple[bytes, PlayerPrefsChanges]: + """按严格白名单更新 PlayerPrefs XML,保留所有其他设置。 + + Args: + content: 原始 PlayerPrefs XML 字节。 + + Returns: + 更新后的 XML 与变更摘要。 + + Raises: + PlayerPrefsUnsupported: XML 格式未知或包含无法安全处理的目标键。 + """ + try: + root = etree.fromstring(content) + except etree.ParseError as error: + raise PlayerPrefsUnsupported(f'PlayerPrefs XML 解析失败: {error}') from None + + entries = _index_target_entries(root) + static_changed = 0 + for name, value in RECOMMENDED_INT_SETTINGS.items(): + static_changed += _set_int(root, entries, name, value) + + for name, value in RECOMMENDED_STRING_SETTINGS.items(): + static_changed += _set_string(root, entries, name, value) + + # 剧情速度按玩家 ID 分键存储;只更新已存在的键,不能猜测或新建账号后缀。 + story_speed_keys = tuple(sorted( + name for name in entries if STORY_SPEED_KEY_PATTERN.fullmatch(name) + )) + story_speed_changed = 0 + for name in story_speed_keys: + story_speed_changed += _set_int(root, entries, name, STORY_SPEED_VALUE) + + standby_keys = tuple(sorted(name for name in entries if STANDBY_MODE_KEY_PATTERN.fullmatch(name))) + standby_changed = 0 + for name in standby_keys: + standby_changed += _set_int(root, entries, name, 0) + + changes = PlayerPrefsChanges( + static_changed=static_changed, + story_speed_changed=story_speed_changed, + standby_changed=standby_changed, + story_speed_keys=story_speed_keys, + standby_keys=standby_keys, + ) + return _serialize_xml(root), changes + + +def verify_player_prefs_xml( + content: bytes, + standby_keys: tuple[str, ...], + story_speed_keys: tuple[str, ...] = (), +) -> None: + """验证目标设置是否全部已写入预期值。""" + try: + root = etree.fromstring(content) + except etree.ParseError as error: + raise PlayerPrefsWriteError(f'回读的 PlayerPrefs XML 解析失败: {error}') from None + + entries = _index_target_entries(root) + for name, value in RECOMMENDED_INT_SETTINGS.items(): + element = entries.get(name) + if element is None or element.tag != 'int' or element.get('value') != str(value): + raise PlayerPrefsWriteError(f'目标键 {name!r} 的回读值不正确') + + for name, value in RECOMMENDED_STRING_SETTINGS.items(): + element = entries.get(name) + actual = '' if element is None or element.text is None else element.text + if element is None or element.tag != 'string' or actual != value: + raise PlayerPrefsWriteError(f'目标键 {name!r} 的回读值不正确') + + for name in story_speed_keys: + element = entries.get(name) + if element is None or element.tag != 'int' or element.get('value') != str(STORY_SPEED_VALUE): + raise PlayerPrefsWriteError('剧情自动播放速度设置的回读值不正确') + + for name in standby_keys: + element = entries.get(name) + if element is None or element.tag != 'int' or element.get('value') != '0': + raise PlayerPrefsWriteError('待机模式设置的回读值不正确') + + +@contextmanager +def _device_lock(serial: str, package: str, timeout: float = 10) -> None: + """用 serial 和包名派生跨进程锁,避免多实例同时替换同一文件。""" + key = hashlib.sha256(f'{serial}\0{package}'.encode('utf-8')).hexdigest()[:16] + lock_file = Path('cache') / f'game-settings-{key}.lock' + lock_file.parent.mkdir(parents=True, exist_ok=True) + + with lock_file.open('a+b') as handle: + handle.seek(0) + if not handle.read(1): + handle.seek(0) + handle.write(b'0') + handle.flush() + + deadline = time.monotonic() + timeout + while True: + try: + if os.name == 'nt': + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError as error: + if time.monotonic() >= deadline: + raise PlayerPrefsUnsupported('等待另一实例的游戏设置事务超时') from None + time.sleep(0.1) + + try: + yield + finally: + if os.name == 'nt': + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +class PlayerPrefsManager: + """通过 root ADB 原子更新碧蓝航线的 PlayerPrefs 文件。""" + + def __init__(self, device, wait_for_stop: bool = False): + self.device = device + self.wait_for_stop = wait_for_stop + self.package = str(device.package) + self._root_enabled_by_transaction = False + self._use_su = False + + def _run_adb( + self, + args: list[str], + *, + timeout: float = 15, + check: bool = True, + error_type: type[PlayerPrefsError] = PlayerPrefsUnsupported, + ) -> AdbResult: + """执行带退出码检查的 host ADB 命令。""" + command = [str(self.device.adb_binary), '-s', str(self.device.serial), *map(str, args)] + try: + completed = subprocess.run( + command, + capture_output=True, + check=False, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise error_type(f'ADB 命令无法执行: {args[0]}') from None + + result = AdbResult( + returncode=completed.returncode, + stdout=completed.stdout.decode('utf-8', errors='replace').strip(), + stderr=completed.stderr.decode('utf-8', errors='replace').strip(), + ) + if check and result.returncode != 0: + raise error_type(f'ADB 命令执行失败: {args[0]}') + return result + + def _run_adb_bytes( + self, + args: list[str], + *, + input_data: bytes | None = None, + timeout: float = 15, + error_type: type[PlayerPrefsError] = PlayerPrefsUnsupported, + ) -> bytes: + """通过 ADB 传输二进制数据,绝不将 PlayerPrefs 写入本地文件。""" + command = [str(self.device.adb_binary), '-s', str(self.device.serial), *map(str, args)] + try: + completed = subprocess.run( + command, + input=input_data, + capture_output=True, + check=False, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired): + raise error_type(f'ADB 二进制传输失败: {args[0]}') from None + if completed.returncode != 0: + raise error_type(f'ADB 二进制传输失败: {args[0]}') + return completed.stdout + + def _shell( + self, + args: list[str], + *, + timeout: float = 15, + check: bool = True, + error_type: type[PlayerPrefsError] = PlayerPrefsUnsupported, + ) -> AdbResult: + if self._use_su: + args = ['su', '-c', shlex.join(map(str, args))] + return self._run_adb( + ['shell', *args], + timeout=timeout, + check=check, + error_type=error_type, + ) + + def _ensure_root(self) -> bool: + """确认 adbd 为 root,或回退到可用的 ``su -c``。""" + current = self._shell(['id'], check=False) + if 'uid=0(root)' in current.stdout: + return True + + self._run_adb(['root'], check=False) + deadline = time.monotonic() + 6 + while time.monotonic() < deadline: + current = self._shell(['id'], check=False) + if 'uid=0(root)' in current.stdout: + self._root_enabled_by_transaction = True + return True + time.sleep(0.25) + + su = self._run_adb(['shell', 'su', '-c', 'id'], check=False) + if 'uid=0(root)' in su.stdout: + self._use_su = True + return True + return False + + def _restore_root_state(self) -> None: + """仅在本事务提权过时恢复为非 root adbd,避免改变用户原有状态。""" + if not self._root_enabled_by_transaction: + return + try: + self._run_adb(['unroot'], check=False) + deadline = time.monotonic() + 6 + while time.monotonic() < deadline: + current = self._shell(['id'], check=False) + if 'uid=0(root)' not in current.stdout: + return + time.sleep(0.25) + except PlayerPrefsError: + pass + logger.warning('[GameSettings] 无法恢复 adbd 的原始非 root 状态') + + def _game_is_stopped(self) -> bool | None: + """确认包及其子进程均不在运行;无法确认时返回 None。""" + pidof = self._shell(['pidof', self.package], check=False) + if pidof.stdout: + return False + + processes = self._shell(['ps', '-A', '-o', 'NAME'], check=False) + if processes.returncode != 0: + return None + for process in processes.stdout.splitlines(): + process = process.strip() + if process == self.package or process.startswith(f'{self.package}:'): + return False + return True + + def _wait_until_game_stopped(self) -> bool: + """重启流程中短暂轮询应用退出,其他启动路径只检查一次。""" + deadline = time.monotonic() + (8 if self.wait_for_stop else 0) + while True: + stopped = self._game_is_stopped() + if stopped is not False: + return stopped is True + if time.monotonic() >= deadline: + return False + time.sleep(0.25) + + def _prefs_path(self) -> str: + """定位 Unity PlayerPrefs 文件,文件名不符时拒绝猜测。""" + if not PACKAGE_PATTERN.fullmatch(self.package): + raise PlayerPrefsUnsupported('游戏包名格式不安全') + + directory = f'/data/user/0/{self.package}/shared_prefs' + expected = f'{directory}/{self.package}.v2.playerprefs.xml' + if self._shell(['test', '-f', expected], check=False).returncode == 0: + return expected + + files = self._shell(['ls', '-1', directory], check=False) + if files.returncode != 0: + raise PlayerPrefsUnsupported('未找到游戏的 PlayerPrefs 目录') + candidates = [ + name for name in files.stdout.splitlines() + if PREFS_FILE_PATTERN.fullmatch(name) + ] + if len(candidates) != 1: + raise PlayerPrefsUnsupported('无法唯一定位游戏的 PlayerPrefs 文件') + return f'{directory}/{candidates[0]}' + + def _ensure_no_atomic_backup(self, prefs: str) -> None: + """避免 Android 未完成的原子写入在下次启动时覆盖主文件。""" + result = self._shell(['test', '-e', f'{prefs}.bak'], check=False) + if result.returncode == 0: + raise PlayerPrefsUnsupported('检测到未完成的应用偏好写入,拒绝覆盖') + if result.returncode != 1: + raise PlayerPrefsUnsupported('无法确认应用偏好写入状态') + + def _metadata( + self, + remote: str, + error_type: type[PlayerPrefsError] = PlayerPrefsUnsupported, + ) -> PlayerPrefsMetadata: + metadata = self._shell(['stat', '-c', '%u:%g:%a', remote], error_type=error_type).stdout + match = METADATA_PATTERN.fullmatch(metadata) + if match is None: + raise error_type('无法读取 PlayerPrefs 文件权限') + + label = self._shell(['ls', '-Zd', remote], error_type=error_type).stdout.split(maxsplit=1) + if not label or not SELINUX_CONTEXT_PATTERN.fullmatch(label[0]): + raise error_type('无法读取 PlayerPrefs 文件的 SELinux 上下文') + + return PlayerPrefsMetadata( + uid=match.group('uid'), + gid=match.group('gid'), + mode=match.group('mode'), + context=label[0], + ) + + def _restore_metadata(self, remote: str, metadata: PlayerPrefsMetadata) -> None: + self._shell( + ['chown', f'{metadata.uid}:{metadata.gid}', remote], + error_type=PlayerPrefsWriteError, + ) + self._shell(['chmod', metadata.mode, remote], error_type=PlayerPrefsWriteError) + self._shell(['chcon', metadata.context, remote], error_type=PlayerPrefsWriteError) + if self._metadata(remote, error_type=PlayerPrefsWriteError) != metadata: + raise PlayerPrefsWriteError('PlayerPrefs 临时文件的元数据校验失败') + + def _read_remote_bytes(self, remote: str, error_type: type[PlayerPrefsError]) -> bytes: + """直接读入内存,不产生本地副本。""" + args = ['exec-out', 'cat', remote] + if self._use_su: + args = ['exec-out', 'su', '-c', shlex.join(['cat', remote])] + return self._run_adb_bytes(args, timeout=30, error_type=error_type) + + def _write_remote_bytes( + self, + remote: str, + content: bytes, + error_type: type[PlayerPrefsError], + ) -> None: + """从内存写入同目录临时文件,供原子替换使用。""" + command = ['exec-in', 'sh', '-c', f'cat > {remote}'] + if self._use_su: + command = ['exec-in', 'su', '-c', shlex.join(['sh', '-c', f'cat > {remote}'])] + self._run_adb_bytes( + command, + input_data=content, + timeout=30, + error_type=error_type, + ) + + def _cleanup_stale_transaction_files(self, prefs: str) -> None: + """清理本模块旧版遗留副本和中断事务的临时文件,不记录文件名。""" + if self._game_is_stopped() is not True: + raise PlayerPrefsUnsupported('游戏进程在清理敏感临时数据前启动,已取消本次写入') + + directory, filename = prefs.rsplit('/', maxsplit=1) + files = self._shell(['ls', '-1', directory], error_type=PlayerPrefsUnsupported).stdout.splitlines() + prefix = f'{filename}.alas-' + stale_files = [] + for name in files: + if not name.startswith(prefix): + continue + suffix = name[len(filename):] + if ( + LEGACY_TRANSACTION_SUFFIX_PATTERN.fullmatch(suffix) + or LEGACY_TEMPORARY_TRANSACTION_SUFFIX_PATTERN.fullmatch(suffix) + or TEMPORARY_TRANSACTION_SUFFIX_PATTERN.fullmatch(suffix) + ): + stale_files.append(f'{directory}/{name}') + + for remote in stale_files: + if self._game_is_stopped() is not True: + raise PlayerPrefsUnsupported('游戏进程在清理敏感临时数据期间启动,已取消本次写入') + self._shell(['rm', '-f', remote], error_type=PlayerPrefsUnsupported) + + def _restore_original( + self, + target: str, + metadata: PlayerPrefsMetadata, + original: bytes, + temporary: str, + ) -> bool: + """只用内存中的原文恢复目标,并确认内容与原始文件完全一致。""" + if self._game_is_stopped() is not True: + return False + try: + self._write_remote_bytes(temporary, original, PlayerPrefsWriteError) + self._restore_metadata(temporary, metadata) + if self._read_remote_bytes(temporary, PlayerPrefsWriteError) != original: + return False + if self._game_is_stopped() is not True: + return False + self._shell(['mv', temporary, target], error_type=PlayerPrefsWriteError) + return ( + self._read_remote_bytes(target, PlayerPrefsWriteError) == original + and self._metadata(target, error_type=PlayerPrefsWriteError) == metadata + ) + except PlayerPrefsError: + return False + + def _apply_locked(self) -> bool: + if not self._wait_until_game_stopped(): + raise PlayerPrefsUnsupported('游戏进程仍在运行,已跳过本次写入') + if not self._ensure_root(): + raise PlayerPrefsUnsupported('ADB 未获得 root 权限') + if not self._wait_until_game_stopped(): + raise PlayerPrefsUnsupported('游戏进程在提权期间启动,已取消本次写入') + + prefs = self._prefs_path() + self._cleanup_stale_transaction_files(prefs) + self._ensure_no_atomic_backup(prefs) + metadata = self._metadata(prefs) + temporary = f'{prefs}.alas-tmp-{secrets.token_hex(8)}.tmp' + replace_attempted = False + original = b'' + + try: + original = self._read_remote_bytes(prefs, PlayerPrefsUnsupported) + modified, changes = update_player_prefs_xml(original) + if not changes.changed: + logger.info('[GameSettings] 推荐的游戏本地设置已符合,无需写入') + return True + + if not self._wait_until_game_stopped(): + raise PlayerPrefsUnsupported('游戏进程在写入前启动,已取消本次写入') + + self._write_remote_bytes(temporary, modified, PlayerPrefsWriteError) + self._restore_metadata(temporary, metadata) + if self._read_remote_bytes(temporary, PlayerPrefsWriteError) != modified: + raise PlayerPrefsWriteError('PlayerPrefs 临时写入内容校验失败') + + if not self._wait_until_game_stopped(): + raise PlayerPrefsUnsupported('游戏进程在替换前启动,已取消本次写入') + replace_attempted = True + self._shell(['mv', temporary, prefs], error_type=PlayerPrefsWriteError) + applied = self._read_remote_bytes(prefs, PlayerPrefsWriteError) + verify_player_prefs_xml( + applied, + changes.standby_keys, + changes.story_speed_keys, + ) + if self._metadata(prefs, error_type=PlayerPrefsWriteError) != metadata: + raise PlayerPrefsWriteError('写入后的 PlayerPrefs 文件元数据不正确') + except PlayerPrefsError: + if replace_attempted: + restored = self._restore_original(prefs, metadata, original, temporary) + if not restored: + logger.critical('[GameSettings] 本地设置写入失败且内存恢复失败,已阻止启动游戏') + raise RequestHumanTakeover from None + logger.warning('[GameSettings] 本地设置写入失败,已恢复原始状态') + else: + logger.warning('[GameSettings] 本地设置写入未完成,原文件未被替换') + return False + finally: + try: + self._shell(['rm', '-f', temporary], check=False) + except PlayerPrefsError: + logger.warning('[GameSettings] 无法清理本次临时写入数据') + + logger.info( + '[GameSettings] 已写入 %s 项静态设置、%s 项剧情速度设置和 %s 项待机模式设置', + changes.static_changed, + changes.story_speed_changed, + changes.standby_changed, + ) + return True + + def apply(self) -> bool: + """安全应用推荐设置;无法安全执行时不影响常规启动。""" + if getattr(self.device, 'is_over_http', False): + logger.warning('[GameSettings] HTTP 设备不支持游戏本地设置自动配置,已跳过') + return False + try: + with _device_lock(str(self.device.serial), self.package): + return self._apply_locked() + except PlayerPrefsUnsupported: + logger.warning('[GameSettings] 已跳过游戏本地设置自动配置(安全检查未通过)') + return False + finally: + self._restore_root_state() + + +def apply_recommended_game_settings(device, wait_for_stop: bool = False) -> bool: + """为当前设备应用推荐设置的唯一运行时入口。""" + return PlayerPrefsManager(device, wait_for_stop=wait_for_stop).apply() diff --git a/module/logger.py b/module/logger.py index 4f3ebf1aa..7bc17e1fd 100644 --- a/module/logger.py +++ b/module/logger.py @@ -561,8 +561,11 @@ def show(): # 异常发生后的行 -def error_context(title, reason, impact, action, exc=None, level=logging.ERROR): - """输出包含原因、影响和处理建议的统一错误信息。""" +def error_context(title, reason, impact, action, exc=None, level=logging.ERROR, with_traceback=None): + """输出包含原因、影响和处理建议的统一错误信息。 + + ``with_traceback`` 为 ``None`` 时,保持原有行为:传入异常对象则输出完整堆栈。 + """ message = '\n'.join([ f'[错误] {title}', f'原因:{reason}', @@ -571,7 +574,9 @@ def error_context(title, reason, impact, action, exc=None, level=logging.ERROR): ]) if exc is not None: message += f'\n异常:{type(exc).__name__}: {exc}' - logger.log(level, message, exc_info=exc is not None) + if with_traceback is None: + with_traceback = exc is not None + logger.log(level, message, exc_info=with_traceback) def exception_context(title, exc, impact, action, level=logging.ERROR): diff --git a/tests/test_alas_error_handling.py b/tests/test_alas_error_handling.py new file mode 100644 index 000000000..fcb94b675 --- /dev/null +++ b/tests/test_alas_error_handling.py @@ -0,0 +1,55 @@ +import logging +import unittest +from unittest.mock import Mock, patch + +from alas import AzurLaneAutoScript +from module.exception import GameNotRunningError +from module.logger import error_context + + +class TestErrorContext(unittest.TestCase): + def test_can_log_exception_summary_without_traceback(self): + error = GameNotRunningError('Game not running') + + with patch('module.logger.logger.log') as log: + error_context( + title='游戏进程未运行', + reason='任务执行前未检测到碧蓝航线游戏进程。', + impact='当前任务跳过。', + action='自动重启游戏。', + exc=error, + level=logging.WARNING, + with_traceback=False, + ) + + self.assertFalse(log.call_args.kwargs['exc_info']) + self.assertIn('异常:GameNotRunningError: Game not running', log.call_args.args[1]) + + +class TestGameNotRunningErrorHandling(unittest.TestCase): + def test_schedules_restart_without_requesting_traceback(self): + script = AzurLaneAutoScript.__new__(AzurLaneAutoScript) + script.config_name = 'test' + script.__dict__['config'] = Mock() + script.config.cross_get.return_value = False + error = GameNotRunningError('Game not running') + script.__dict__['commission'] = Mock(side_effect=error) + + with ( + patch('alas.logger.error_context') as error_context_mock, + patch('alas.handle_notify'), + patch('alas.notify_webui'), + ): + result = script.run('commission', skip_first_screenshot=True) + + self.assertEqual('recoverable', result) + script.config.task_call.assert_called_once_with('Restart') + error_context_mock.assert_called_once_with( + title='游戏进程未运行', + reason='任务执行前未检测到碧蓝航线游戏进程。', + impact='当前任务跳过,调度器将自动安排 Restart 任务。', + action='通常无需处理;若反复发生,请检查游戏包名、模拟器状态和登录流程。', + exc=error, + level=30, + with_traceback=False, + ) diff --git a/tests/test_game_setting_player_prefs.py b/tests/test_game_setting_player_prefs.py new file mode 100644 index 000000000..79cdd4b58 --- /dev/null +++ b/tests/test_game_setting_player_prefs.py @@ -0,0 +1,281 @@ +import unittest +import xml.etree.ElementTree as etree +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import Mock, call, patch + +from module.game_setting.player_prefs import ( + AdbResult, + PlayerPrefsManager, + PlayerPrefsMetadata, + RECOMMENDED_INT_SETTINGS, + RECOMMENDED_STRING_SETTINGS, + PlayerPrefsUnsupported, + PlayerPrefsWriteError, + update_player_prefs_xml, + verify_player_prefs_xml, +) + + +class TestPlayerPrefsXml(unittest.TestCase): + EXPECTED_SETTINGS = { + 'fps_limit': 60, + 'world_flag_story_tips': 1, + 'world_flag_consume_item': 1, + 'world_flag_auto_save_area': 0, + 'story_autoplay_flag': 1, + 'display_ship_get_effect': 0, + 'QUICK_CHANGE_EQUIP': 0, + 'BATTLERESULT_DISPAY_PAINTING': 0, + 'world_sub_auto_call': 0, + } + + def test_updates_only_whitelisted_settings(self): + content = b''' + + + + + + all + keep me +''' + + updated, changes = update_player_prefs_xml(content) + root = etree.fromstring(updated) + entries = {element.get('name'): element for element in root} + + self.assertTrue(changes.changed) + self.assertEqual(1, changes.story_speed_changed) + self.assertEqual(('story_speed_flag123456',), changes.story_speed_keys) + self.assertEqual(1, changes.standby_changed) + self.assertEqual(('STANDBY_MODE_KEY_123456',), changes.standby_keys) + self.assertEqual(self.EXPECTED_SETTINGS, RECOMMENDED_INT_SETTINGS) + self.assertEqual({'_WorldBossProgressTipFlag_': ''}, RECOMMENDED_STRING_SETTINGS) + for name, value in self.EXPECTED_SETTINGS.items(): + self.assertEqual('int', entries[name].tag) + self.assertEqual(str(value), entries[name].get('value')) + self.assertEqual('9', entries['story_speed_flag123456'].get('value')) + self.assertEqual('string', entries['_WorldBossProgressTipFlag_'].tag) + self.assertEqual('', entries['_WorldBossProgressTipFlag_'].text or '') + self.assertEqual('0', entries['STANDBY_MODE_KEY_123456'].get('value')) + self.assertEqual('9', entries['world_sub_call_line'].get('value')) + self.assertEqual('all', entries['auto_switch_difficult_safe'].text) + self.assertEqual('keep me', entries['unrelated_setting'].text) + self.assertNotIn('STANDBY_MODE_KEY', entries) + verify_player_prefs_xml(updated, changes.standby_keys, changes.story_speed_keys) + + def test_second_update_is_idempotent(self): + content = b'' + updated, _ = update_player_prefs_xml(content) + _, changes = update_player_prefs_xml(updated) + + self.assertFalse(changes.changed) + self.assertEqual((), changes.story_speed_keys) + self.assertEqual(('STANDBY_MODE_KEY_7',), changes.standby_keys) + + def test_does_not_create_dynamic_key_without_player_id(self): + updated, changes = update_player_prefs_xml(b'') + root = etree.fromstring(updated) + + self.assertEqual((), changes.standby_keys) + self.assertEqual((), changes.story_speed_keys) + self.assertFalse(any( + element.get('name', '').startswith('STANDBY_MODE_KEY') + for element in root + )) + self.assertFalse(any( + element.get('name', '').startswith('story_speed_flag') + for element in root + )) + + def test_verification_rejects_wrong_target_values(self): + updated, changes = update_player_prefs_xml( + b'' + ) + root = etree.fromstring(updated) + entries = {element.get('name'): element for element in root} + entries['story_speed_flag7'].set('value', '5') + wrong_story_speed = etree.tostring(root, encoding='utf-8') + + with self.assertRaises(PlayerPrefsWriteError): + verify_player_prefs_xml(wrong_story_speed, changes.standby_keys, changes.story_speed_keys) + + entries['story_speed_flag7'].set('value', '9') + entries['_WorldBossProgressTipFlag_'].text = '100&200' + wrong_beacon_tip = etree.tostring(root, encoding='utf-8') + + with self.assertRaises(PlayerPrefsWriteError): + verify_player_prefs_xml(wrong_beacon_tip, changes.standby_keys, changes.story_speed_keys) + + entries['_WorldBossProgressTipFlag_'].text = None + entries['STANDBY_MODE_KEY_7'].set('value', '1') + wrong_standby = etree.tostring(root, encoding='utf-8') + with self.assertRaises(PlayerPrefsWriteError): + verify_player_prefs_xml(wrong_standby, changes.standby_keys, changes.story_speed_keys) + + def test_rejects_target_key_with_wrong_xml_type(self): + with self.assertRaises(PlayerPrefsUnsupported): + update_player_prefs_xml(b'60') + + with self.assertRaises(PlayerPrefsUnsupported): + update_player_prefs_xml(b'') + + def test_rejects_unknown_root_node(self): + with self.assertRaises(PlayerPrefsUnsupported): + update_player_prefs_xml(b'') + + +class TestPlayerPrefsRollback(unittest.TestCase): + def test_rollback_uses_memory_and_atomic_replace(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + metadata = PlayerPrefsMetadata(uid='10000', gid='10000', mode='660', context='u:object_r:test:s0') + original = b'' + manager._game_is_stopped = Mock(return_value=True) + manager._shell = Mock(return_value=AdbResult(0, '', '')) + manager._write_remote_bytes = Mock() + manager._restore_metadata = Mock() + manager._read_remote_bytes = Mock(side_effect=[original, original]) + manager._metadata = Mock(return_value=metadata) + + restored = manager._restore_original( + '/remote/prefs.xml', + metadata, + original, + '/remote/prefs.rollback.tmp', + ) + + self.assertTrue(restored) + manager._write_remote_bytes.assert_called_once_with( + '/remote/prefs.rollback.tmp', original, PlayerPrefsWriteError + ) + self.assertEqual( + [call(['mv', '/remote/prefs.rollback.tmp', '/remote/prefs.xml'], error_type=PlayerPrefsWriteError)], + manager._shell.call_args_list, + ) + manager._restore_metadata.assert_called_once_with('/remote/prefs.rollback.tmp', metadata) + + def test_rollback_refuses_to_replace_when_game_restarts(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + manager._game_is_stopped = Mock(return_value=False) + manager._shell = Mock() + manager._write_remote_bytes = Mock() + + restored = manager._restore_original( + '/remote/prefs.xml', + PlayerPrefsMetadata(uid='10000', gid='10000', mode='660', context='u:object_r:test:s0'), + b'', + '/remote/prefs.rollback.tmp', + ) + + self.assertFalse(restored) + manager._shell.assert_not_called() + manager._write_remote_bytes.assert_not_called() + + +class TestPlayerPrefsPrivacy(unittest.TestCase): + def test_cleanup_ignores_android_atomic_backup(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + prefs = '/remote/prefs.xml' + manager._game_is_stopped = Mock(return_value=True) + manager._shell = Mock(side_effect=[ + AdbResult(0, '\n'.join([ + 'prefs.xml.bak', + 'prefs.xml.alas-20260726-120000-0123456789ab.bak', + 'prefs.xml.alas-tmp-0123456789abcdef.tmp', + 'prefs.xml.unrelated.bak', + ]), ''), + AdbResult(0, '', ''), + AdbResult(0, '', ''), + ]) + + manager._cleanup_stale_transaction_files(prefs) + + self.assertEqual( + [ + call(['ls', '-1', '/remote'], error_type=PlayerPrefsUnsupported), + call(['rm', '-f', '/remote/prefs.xml.alas-20260726-120000-0123456789ab.bak'], error_type=PlayerPrefsUnsupported), + call(['rm', '-f', '/remote/prefs.xml.alas-tmp-0123456789abcdef.tmp'], error_type=PlayerPrefsUnsupported), + ], + manager._shell.call_args_list, + ) + + def test_skip_log_does_not_contain_exception_details(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + manager._apply_locked = Mock(side_effect=PlayerPrefsUnsupported('/sensitive/path/uuid.xml')) + + with ( + patch('module.game_setting.player_prefs._device_lock', return_value=nullcontext()), + patch('module.game_setting.player_prefs.logger.warning') as warning, + ): + result = manager.apply() + + self.assertFalse(result) + warning.assert_called_once_with('[GameSettings] 已跳过游戏本地设置自动配置(安全检查未通过)') + + +class TestPlayerPrefsRootModes(unittest.TestCase): + def test_falls_back_to_su_when_adb_root_is_unavailable(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + manager._shell = Mock(return_value=AdbResult(1, 'uid=2000(shell)', '')) + manager._run_adb = Mock(side_effect=[ + AdbResult(1, '', ''), + AdbResult(0, 'uid=0(root)', ''), + ]) + + with patch('module.game_setting.player_prefs.time.monotonic', side_effect=[0, 6]): + self.assertTrue(manager._ensure_root()) + + self.assertTrue(manager._use_su) + self.assertEqual( + [ + call(['root'], check=False), + call(['shell', 'su', '-c', 'id'], check=False), + ], + manager._run_adb.call_args_list, + ) + + def test_su_mode_wraps_shell_and_binary_transfers(self): + manager = PlayerPrefsManager( + SimpleNamespace(package='com.example.game', adb_binary='adb', serial='serial') + ) + manager._use_su = True + manager._run_adb = Mock(return_value=AdbResult(0, '', '')) + manager._run_adb_bytes = Mock(return_value=b'') + + manager._shell(['stat', '-c', '%u:%g:%a', '/remote/prefs.xml']) + content = manager._read_remote_bytes('/remote/prefs.xml', PlayerPrefsUnsupported) + manager._write_remote_bytes('/remote/prefs.tmp', content, PlayerPrefsWriteError) + + manager._run_adb.assert_called_once_with( + ['shell', 'su', '-c', 'stat -c %u:%g:%a /remote/prefs.xml'], + timeout=15, + check=True, + error_type=PlayerPrefsUnsupported, + ) + self.assertEqual( + [ + call( + ['exec-out', 'su', '-c', 'cat /remote/prefs.xml'], + timeout=30, + error_type=PlayerPrefsUnsupported, + ), + call( + ['exec-in', 'su', '-c', "sh -c 'cat > /remote/prefs.tmp'"], + input_data=b'', + timeout=30, + error_type=PlayerPrefsWriteError, + ), + ], + manager._run_adb_bytes.call_args_list, + ) From 31b69bfb0805289d64e18d70486cb71badd7bd2d Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Sun, 26 Jul 2026 18:58:43 +0800 Subject: [PATCH 2/7] =?UTF-8?q?i18n=E6=96=87=E4=BB=B6=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/config/i18n/en-US.json | 455 +++++++++++++++++----------------- module/config/i18n/ja-JP.json | 388 +++++++++++++++-------------- module/config/i18n/zh-CN.json | 258 +++++++++---------- module/config/i18n/zh-TW.json | 272 ++++++++++---------- 4 files changed, 692 insertions(+), 681 deletions(-) diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index c59fc4682..03adf7588 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -1,15 +1,15 @@ { "Menu": { "Alas": { - "name": "Smart Port Area+", + "name": "Smart Port Plus", "help": "" }, "Farm": { - "name": "Sortie+", + "name": "Sortie Plus", "help": "" }, "Event": { - "name": "Event+", + "name": "Event Plus", "help": "" }, "EventDaily": { @@ -25,7 +25,7 @@ "help": "" }, "Opsi": { - "name": "Operation Siren+", + "name": "Operation Siren Plus", "help": "" }, "Island": { @@ -33,7 +33,7 @@ "help": "" }, "Tool": { - "name": "Tools+", + "name": "Tools Plus", "help": "" } }, @@ -51,23 +51,23 @@ "help": "" }, "Main": { - "name": "Main Campaign-1+", + "name": "Main Campaign-1 Plus", "help": "" }, "Main2": { - "name": "Main Campaign-2+", + "name": "Main Campaign-2 Plus", "help": "" }, "Main3": { - "name": "Main Campaign+", + "name": "Main Campaign-3 Plus", "help": "" }, "GemsFarming": { - "name": "Urgent Commission+", + "name": "Urgent Commission Plus", "help": "" }, "ThreeOilLowCost": { - "name": "3-Oil Low Cost+", + "name": "3-Oil Low Cost Plus", "help": "Use a 3-oil low-cost fleet to fight. Automatically replaces and retires the flagship when it reaches level 32." }, "Ambush11": { @@ -79,23 +79,23 @@ "help": "" }, "Event": { - "name": "Event Map-1+", + "name": "Event Map-1 Plus", "help": "" }, "Event2": { - "name": "Event Map-2+", + "name": "Event Map-2 Plus", "help": "" }, "Event3": { - "name": "Event Map-3+", + "name": "Event Map-3 Plus", "help": "" }, "Raid": { - "name": "Raid Event+", + "name": "Raid Event Plus", "help": "" }, "RaidScuttle": { - "name": "Raid Scuttle+", + "name": "Raid Scuttle Plus", "help": "" }, "Hospital": { @@ -107,8 +107,8 @@ "help": "" }, "CoalitionScuttle": { - "name": "Task.CoalitionScuttle.name", - "help": "Task.CoalitionScuttle.help" + "name": "Coalition Scuttle Plus", + "help": "Farm affinity by having ships sunk in Coalition events. Recommend sortie with multiple fleets; the first fleet sinks. Refer to tutorials for details." }, "MaritimeEscort": { "name": "Merchant Ship Escort", @@ -163,7 +163,7 @@ "help": "" }, "Dorm": { - "name": "Backyard", + "name": "Dorm Plus", "help": "" }, "Meowfficer": { @@ -195,7 +195,7 @@ "help": "" }, "ShopFrequent": { - "name": "Munitions Shop", + "name": "Arms Dealer Plus", "help": "" }, "ShopOnce": { @@ -207,7 +207,7 @@ "help": "" }, "Gacha": { - "name": "Daily Gacha", + "name": "Daily Build Plus", "help": "" }, "Freebies": { @@ -219,7 +219,7 @@ "help": "Automatically collects game coins when they won't overflow. Automatically plays mini-games when there are game coins and rewards are not full." }, "PrivateQuarters": { - "name": "Dormitory Plan", + "name": "Private Quarters Plus", "help": "" }, "OpsiGeneral": { @@ -235,11 +235,11 @@ "help": "" }, "OpsiExplore": { - "name": "Monthly Exploration+", + "name": "Monthly Exploration Plus", "help": "" }, "OpsiShop": { - "name": "OpSi Shop+", + "name": "OpSi Shop Plus", "help": "" }, "OpsiVoucher": { @@ -247,7 +247,7 @@ "help": "" }, "OpsiDaily": { - "name": "OpSi - Daily+", + "name": "OpSi Daily Plus", "help": "" }, "OpsiObscure": { @@ -263,7 +263,7 @@ "help": "Purchase archive coordinates from the Voucher Shop and run them. Archive zones do not consume action points.\nThe following conditions must be met before using this feature:\n- Clear the Operation Siren main story and complete Simulation + Siren Proving Grounds\n- Enable Operation Siren exploration missions or use Campaign Data Logger (5000 oil item)" }, "OpsiStronghold": { - "name": "Siren Fortress", + "name": "Siren Stronghold Plus", "help": "" }, "OpsiMonthBoss": { @@ -271,15 +271,15 @@ "help": "" }, "OpsiMeowfficerFarming": { - "name": "Meowfficer Farming+", + "name": "Meowfficer Farming", "help": "" }, "OpsiHazard1Leveling": { - "name": "Corrosion 1 Leveling+", + "name": "Corrosion 1 Leveling", "help": "" }, "OpsiScheduling": { - "name": "Smart Scheduling+", + "name": "Smart Scheduling Plus", "help": "Operation Siren smart scheduling function, used to coordinate switching between Corrosion 1 leveling and gold coin replenishment tasks.\nOnce enabled, it will automatically switch between different tasks and send push notifications based on gold coins and action points." }, "OpsiPreventActionPointOverflow": { @@ -401,6 +401,9 @@ "EmulatorManager": { "name": "Emulator Manager", "help": "Provides an entry point for manually restarting the emulator." + }, + "BattlePass": { + "name": "Battle Pass Plus" } }, "Scheduler": { @@ -410,7 +413,7 @@ }, "Enable": { "name": "Enable this feature", - "help": "Add this task to the scheduler\nCommissioning, scientific research, and harvest tasks are mandatory.", + "help": "Add this task to the scheduler\nCommission, Research, and Reward tasks are always enabled", "True": "Enabled", "False": "Disabled" }, @@ -442,7 +445,7 @@ }, "Sensitive": { "name": "Sensitive task", - "help": "When an error occurs, prohibit restarting the game and stop AzurPilot immediately. Suitable for critical tasks like cross-month daily, obscure sea areas, and abyssal sea areas.", + "help": "When an error occurs, prohibit restarting the game and stop AzurPilot immediately. Suitable for critical timing tasks like cross-month daily, Obscured Zones, Abyssal Zones, etc.", "True": "Yes", "False": "No" } @@ -473,7 +476,7 @@ "help": "Common emulator Serial can be found in the list below\nFill in \"auto\" to automatically detect the emulator. \"auto\" cannot be used when multiple emulators are running or an emulator that does not support automatic detection is used and must be filled in manually.\n\nEmulator default Serial:\n- BlueStacks 127.0.0.1:5555\n- BlueStacks 4 Hyper-v version, fill in \"bluestacks4-hyperv\" to automatically connect, fill in \"bluestacks4-hyperv-2\" for more options, and so on.\n- BlueStacks 5 Hyper-v version, fill in \"bluestacks5-hyperv\" to automatically connect, fill in \"bluestacks5-hyperv-1\" for more options, and so on.\n- NoxPlayer 127.0.0.1:62001\n- NoxPlayer 64-bit 127.0.0.1:59865\n- MuMu Player/MuMu Player X 127.0.0.1:7555\n- MuMu Player 12 127.0.0.1:16384\n- MuMu Pro (macOS) 127.0.0.1:16384, the order of opening is 127.0.0.1:16416, 127.0.0.1:16448...\n- MEmu 127.0.0.1:21503\n- LDPlayer emulator-5554 or 127.0.0.1:5555\nIf you use the multi-instance function of the emulator, their Serial will not be the default. You can execute `adb devices` query in console.bat, or fill it in according to the official tutorial of the emulator." }, "PackageName": { - "name": "game server", + "name": "Game Server", "help": "When multiple game clients are installed on the emulator, you need to manually select the server", "auto": "Automatic detection", "com.bilibili.azurlane": "National service", @@ -575,7 +578,7 @@ "jp-17": "[JP] スイートバン" }, "ScreenshotMethod": { - "name": "Emulator Screenshot Method", + "name": "Select Screenshot Method", "help": "When using auto-select, a performance test will be performed and automatically changed to the fastest screenshot method\nGeneral speed: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB\nRun Tools - Performance Test to find the fastest method", "auto": "Auto-select fastest", "ADB": "ADB", @@ -589,7 +592,7 @@ "ldopengl": "ldopengl" }, "ControlMethod": { - "name": "Emulator Control Method", + "name": "Select Control Method", "help": "Speed: minitouch > Hermit >>> uiautomator2 ~= ADB\nRecommended: minitouch. Not recommended: Hermit unless running under vmos.", "ADB": "ADB", "uiautomator2": "uiautomator2", @@ -618,7 +621,7 @@ "help": "The following values are automatically filled in based on Serial. If you do not understand, please do not modify it at will. Modifying the port will automatically start the corresponding instance." }, "Emulator": { - "name": "Emulator type", + "name": "Select Emulator Type", "help": "", "auto": "Automatic detection", "NoxPlayer": "NoxPlayer", @@ -693,15 +696,15 @@ }, "StrictRestart": { "name": "Prohibit restarting when errors occur in sensitive tasks", - "help": "Stop AzurPilot when an error occurs while running sensitive tasks instead of restarting the game\nSensitive tasks include hidden sea areas, abyssal sea areas, and cross-month daily tasks." + "help": "When an error occurs, directly terminate AzurPilot without restarting the game. Use for debugging, commission tasks, research tasks, Obscured Zones, Abyssal Zones, and other critical timing tasks" }, "SaveErrorCount": { - "name": "Error Log storage upper limit", + "name": "Keep Up to X Error Logs", "help": "Error logs exceeding the upper limit will be deleted. If it is 0 or a negative value, there is no limit." }, "OnePushConfig": { - "name": "Wrong push settings", - "help": "After an unhandled exception occurs, use Onepush to push an error message. See the documentation for configuration methods: https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/Onepush-configuration-%5BCN%5D" + "name": "Error Push Settings", + "help": "Notification will be sent via OnePush when an error occurs. Configure the OnePush provider for use, refer to https://github.com/y1ndan/onepush" }, "ScreenshotLength": { "name": "On error, keep the last X screenshots", @@ -712,7 +715,7 @@ "help": "After the game freezes (triggers GameStuckError) for the specified number of times, try restarting the emulator. It may help solve problems such as emulator freeze or memory overflow." }, "GameStuckThreshold": { - "name": "Game stuck restart threshold", + "name": "Restart When Game Stuck for X Seconds", "help": "How many consecutive times it is stuck before triggering a restart." }, "AdbOfflineRestart": { @@ -720,7 +723,7 @@ "help": "After the device cannot be detected continuously (ADB is offline or the connection fails) for the specified number of times, try to restart the emulator." }, "AdbOfflineThreshold": { - "name": "Device restart threshold not detected", + "name": "Restart When Device Unreachable for X Seconds", "help": "The number of times the device cannot be detected in a row triggers a restart." }, "LlmAnalysis": { @@ -895,11 +898,11 @@ "save_and_upload": "Save and Upload" }, "TelemetryReport": { - "name": "Telemetry data reporting", + "name": "Telemetry Data Reporting", "help": "After being enabled, CL1 statistics (anonymous data such as the number of battles and Akashi encounter probability) will be reported anonymously to the server on a regular basis. For details on ID generation, please see the source code. For statistical information https://alas.nanoda.work/" }, "BugReport": { - "name": "Bug log reporting", + "name": "Bug Log Reporting", "help": "When enabled, bug logs will be anonymously reported to the server when an exception is encountered to help improve ID generation. See the source code for details." } }, @@ -909,7 +912,7 @@ "help": "It will take effect after the scheduler is restarted." }, "LogKeepCount": { - "name": "Log retention quantity", + "name": "Log Retention Count", "help": "Log expiration time (days)" }, "LogBackUpMethod": { @@ -934,7 +937,7 @@ "help": "" }, "RetireMode": { - "name": "Retirement mode", + "name": "Select Retirement Mode", "help": "If you choose to strengthen, you will fall back to one-click retirement when the strengthening materials are insufficient.", "one_click_retire": "Retire with one click", "enhance": "Enhance", @@ -973,9 +976,9 @@ "FleetRecover": { "name": "Morale Recovery", "help": "", - "not_in_dormitory": "Not placed in the Backyard (20/h)", - "dormitory_floor_1": "Backyard First Floor (40/h)", - "dormitory_floor_2": "Backyard Second Floor (50/h)" + "not_in_dormitory": "Not in Dorm (20/h)", + "dormitory_floor_1": "Dorm 1st Floor (40/h)", + "dormitory_floor_2": "Dorm 2nd Floor (50/h)" }, "FleetOath": { "name": "All members are married (+10/h)", @@ -1010,28 +1013,28 @@ }, "Enhance": { "_info": { - "name": "Harden settings", - "help": "Only takes effect when \"Retirement Mode\" is set to \"Hardened\"" + "name": "Enhance Settings", + "help": "Only takes effect when \"Retirement Mode\" is set to \"Enhance\"" }, "ShipToEnhance": { - "name": "Ships in need of reinforcement", + "name": "Ships to Enhance", "help": "", - "all": "Strengthen all ships", - "favourite": "Only commonly used ships are strengthened" + "all": "Enhance All Ships", + "favourite": "Enhance Favorite Ships Only" }, "Filter": { "name": "Enhance filter string", "help": "Format: \"cv > bb > ...\",\nLeave blank to use the default enhancement method" }, "CheckPerCategory": { - "name": "Number of reinforcements per category", - "help": "How many ships can be enhanced at most for each ship category?\nShips in combat will be skipped and not counted,\nPrioritize whether there are strengthening materials" + "name": "Check X Ships per Category", + "help": "Prioritizes checking whether enhancement materials are available" } }, "OldRetire": { "_info": { - "name": "Legacy Retirement Settings", - "help": "Only takes effect when \"Retirement Mode\" is set to \"Legacy Retirement\"\nNOTE: Traditional retirement based on character rarity, possible retirement of Bree" + "name": "Traditional Retirement Settings", + "help": "Only takes effect when \"Retirement Mode\" is set to \"Traditional\"\nNote: Traditional retirement is based on rarity, and may retire Bulin" }, "N": { "name": "Retire N (White)", @@ -1050,7 +1053,7 @@ "help": "" }, "RetireAmount": { - "name": "Retire Quantity", + "name": "Retire X Ships Each Time", "help": "", "retire_all": "Retire All", "retire_10": "Retire 10" @@ -1066,9 +1069,9 @@ "help": "For example, 7-2, 12-4, D3, SP4, case-insensitive\nIf the level is missing, please wait for the update" }, "Event": { - "name": "Activity name", - "help": "Automatically select the latest activity", - "campaign_main": "main line diagram", + "name": "Event Name", + "help": "Automatically select the latest event", + "campaign_main": "Main Campaign", "coalition_20230323": "Frostfall", "coalition_20240627": "Welcome to Little Academy", "coalition_20250626": "The Neon City Investigator", @@ -1221,7 +1224,7 @@ "war_archives_20240725_cn": "archives Interlude of Illusions" }, "Mode": { - "name": "Stage Mode", + "name": "Select Stage Mode", "help": "Only applies to main campaign", "normal": "Normal", "hard": "Hard" @@ -1304,7 +1307,7 @@ "help": "" }, "DailyRunCount": { - "name": "Daily Sortie Count", + "name": "Sortie X Times Daily", "help": "Maximum War Archives runs per day.\n0 means no limit.\nAfter reaching the limit, the task is delayed until the next server reset instead of being disabled." }, "DailyRunCountRemain": { @@ -1326,7 +1329,7 @@ "help": "" }, "Fleet1": { - "name": "Fleet 1 Number", + "name": "Fleet 1 Uses Fleet Number X", "help": "", "1": "1 ", "2": "2 ", @@ -1343,7 +1346,7 @@ "diamond": "Diamond" }, "Fleet1Mode": { - "name": "Fleet 1 Auto Mode", + "name": "Fleet 1 Auto Battle Mode", "help": "", "combat_auto": "Auto Battle", "combat_manual": "Manual", @@ -1360,7 +1363,7 @@ "5": "5 " }, "Fleet2": { - "name": "Fleet 2 Number", + "name": "Fleet 2 Uses Fleet Number X", "help": "", "0": "Not used", "1": "1 ", @@ -1378,7 +1381,7 @@ "diamond": "Diamond" }, "Fleet2Mode": { - "name": "Fleet 2 Auto Mode", + "name": "Fleet 2 Auto Battle Mode", "help": "", "combat_auto": "Auto Battle", "combat_manual": "Manual", @@ -1404,7 +1407,7 @@ }, "SkipPreparation": { "name": "Skip Fleet Preparation", - "help": "Skip the fleet selection step in the fleet preparation screen and use the game's current pre-selected fleet.\nUseful for accounts that have not unlocked all fleet slots, avoiding dropdown menu detection freeze.\nMake sure you have manually selected Fleet 1 and Fleet 2 in-game before enabling." + "help": "When enabled, skips the fleet selection step in the preparation screen and directly uses the currently pre-selected fleet for sortie\nSuitable for accounts with unlocked fleet slots, to avoid dropdown detection getting stuck\nBefore enabling, make sure Fleet 1 and Fleet 2 are manually selected in-game" } }, "Submarine": { @@ -1413,14 +1416,14 @@ "help": "" }, "Fleet": { - "name": "Submarine Fleet Number", + "name": "Use Submarine Fleet X", "help": "", "0": "Not used", "1": "1 ", "2": "2 " }, "Mode": { - "name": "Submarine Attack Mode", + "name": "Select Submarine Sortie Plan", "help": "Only takes effect when auto-search is off. Note: 'Hunt and Boss' combines 'Hunt Only' and 'Boss Only' - submarines will hunt during mob battles and be summoned during boss battles.", "do_not_use": "Not Used", "hunt_only": "Hunt Only", @@ -1429,7 +1432,7 @@ "every_combat": "Every Combat" }, "AutoSearchMode": { - "name": "Submarine Auto-Search Mode", + "name": "Select Submarine Auto Plan", "help": "Only takes effect when using auto-search", "sub_standby": "Standby", "sub_auto_call": "Auto-Summon Submarine" @@ -1474,9 +1477,9 @@ "Fleet1Recover": { "name": "Fleet 1 Morale Recovery", "help": "", - "not_in_dormitory": "Not in Backyard (20/h)", - "dormitory_floor_1": "Backyard 1st Floor (40/h)", - "dormitory_floor_2": "Backyard 2nd Floor (50/h)" + "not_in_dormitory": "Not in Dorm (20/h)", + "dormitory_floor_1": "Dorm 1st Floor (40/h)", + "dormitory_floor_2": "Dorm 2nd Floor (50/h)" }, "Fleet1Oath": { "name": "All Fleet 1 Members Oathed (+10/h)", @@ -1505,9 +1508,9 @@ "Fleet2Recover": { "name": "Fleet 2 Morale Recovery", "help": "", - "not_in_dormitory": "Not in Backyard (20/h)", - "dormitory_floor_1": "Backyard 1st Floor (40/h)", - "dormitory_floor_2": "Backyard 2nd Floor (50/h)" + "not_in_dormitory": "Not in Dorm (20/h)", + "dormitory_floor_1": "Dorm 1st Floor (40/h)", + "dormitory_floor_2": "Dorm 2nd Floor (50/h)" }, "Fleet2Oath": { "name": "All Fleet 2 Members Oathed (+10/h)", @@ -1634,11 +1637,11 @@ "GemsFarming": { "_info": { "name": "Urgent Commission Farming", - "help": "Warning: This feature allows players to obtain a small amount of diamonds, but is not an efficient way to obtain diamonds.\nPlayers who have been playing Azur Lane for a short time can easily become superstitious about zero breakage and low consumption, while neglecting character development.\nRunning this function will only make you feel very fulfilled, but your account will actually have zero improvement.\nThe main purpose of this function is to allow players with a formed lineup to obtain additional Rubik's Cubes. Please treat diamonds rationally as a by-product.\n\nThis feature will replace the flagship after flagship level 32\nRegarding the emergency commission refresh mechanism, you can read https://bbs.nga.cn/read.php?tid=27134956\nCommissioned fuel consumption ratio: A3 > A1 > 2-4 > 2-1. It is recommended to choose the activity diagram when there is an activity diagram. 2-1 will pick the question mark to have more supplies.\nOne team will complete all battles. The front row should be level 100 zero-break destroyer with output equipment, and the back row should be level 33 and below white-skin zero-break aircraft carriers.\nThe second team should provide cross-team support (cross-team barrage, cross-team assistance)\n\nWhen the fleet function cannot be set, the mission should be stopped in the following ways to avoid cross-team attacks and a large amount of oil being consumed:\nIf error push is set, a task stop message will be pushed and the task will be stopped.\nIf error push is not set, AzurPilot will be stopped directly." + "help": "Warning: This feature allows players to earn a small amount of Gems, but it is not an efficient way to farm Gems\nNew Azur Lane players often overvalue limit break avoidance and low oil cost, while neglecting ship development\nRunning this feature only makes you feel productive, while your account actually makes zero progress\nPlease press the return button" }, "ChangeFlagship": { "name": "Change Flagship", - "help": "Change flagship when flagship level >= 32 or red face\nLook for white-skinned aircraft carriers with levels between 1-31, and choose replacement flagships when they retire. All white-skinned aircraft carriers with levels greater than level 1 will be considered decommissioned targets. Please be sure to target ships that are not targets. Change flagship equipment when changing ships, this is done by recording the previous equipment.\n\nChanging equipment will only change the slot in the equipment, even white equipment will be changed. If a flagship is specified, all 5 equipment will be replaced. If a flagship is not specified, only the equipment will be replaced.", + "help": "White-rarity light carriers and white-rarity carriers will be treated as retirement targets\nOnly 0 limit break, no equipment, white-rarity light carriers will be used as replacement\nControls: When to replace the flagship", "ship": "Change ship", "ship_equip": "Change ships + equipment" }, @@ -1655,12 +1658,12 @@ }, "CommonCVFilter": { "name": "Custom Flagship Carrier Filter", - "help": "To use a custom filter, you need to set \"Specify Flagship Carrier\" to \"Custom\", and the flagship carrier will be selected first for replacement according to the order of the filter.\nbogue: Borg ranger: raider langley: Langley hermes: athletic god" + "help": "Filter usable light carriers, syntax same as common CV filter\nSeparated by spaces, case-insensitive\nExample: bogue: Bogue, ranger: Ranger, langley: Langley, hermes: Hermes" }, "ChangeVanguard": { "name": "Change Vanguard", - "help": "Change the front row when the front row is red. If you choose not to change, the red face will be forced to attack.\nTo change the front row, you need to find a white eagle or iron-blooded white-skin ship with a mood of no less than 10 and a level of 100 to drive away, so try to ensure that there are enough to drive away. Outside the national server, it is the level 70 White Eagle White Ship Expulsion.", - "disabled": "Do not replace", + "help": "Replace vanguard with low-oil-cost ships from Eagle Union or Iron Blood\nOnly 0 limit break, no equipment, white-rarity destroyers will be used as replacement\nControls: When to replace the vanguard", + "disabled": "Do Not Change", "ship": "Change ship", "ship_equip": "Change ships + equipment" }, @@ -1677,34 +1680,34 @@ }, "CommonDDFilter": { "name": "Custom front row filter", - "help": "To use a custom filter, you need to set \"Specify Front Row\" to \"Custom\", and the front row will be replaced first according to the filter order.\nz20:Z20 z21:Z21 aulick:Aulick foote:Foot cassin:Cassin downes:Downs" + "help": "Filter usable low-oil-cost vanguard ships\nSeparate by spaces\nfoote: Foote, cassin: Cassin, downes: Downes" }, "EquipmentCode": { "name": "Equipment code", - "help": "Only takes effect when \"Change Ship + Equipment\" is selected.\nWhen changing a ship, if the current ship equipment code in the settings is null, it will be exported; when changing a ship, if the current ship equipment code in the settings is null, the equipment code exported from the previous ship will be used, otherwise the equipment code in the settings will be used.\nBlueStacks emulator users need to enable the virtual keyboard first. For details, see https://support.bluestacks.com/hc/zh-tw/articles/360061968892-%E5%A6%82%E4%BD%95%E5%9C% A8-BlueStacks-5-%E4%B8%8A%E4%BD%BF%E7%94%A8%E5%B0%8F%E9%8D%B5%E7%9B%A4-%E8%99%9B%E6%93%AC%E9%8D%B5%E7%9B%A4\nDD: front row expulsion bogue: Borg hermes: athletic god langley: Langley ranger: raider" + "help": "Default equipment code for light carriers\nSeparate by spaces\nbogue: Bogue, hermes: Hermes, langley: Langley, ranger: Ranger" }, "UseEmotionFirst": { - "name": "Prioritize Mood", + "name": "Prioritize Morale", "help": "When replacing ships, prioritize the highest mood ships." }, "IgnoreEmotionWarning": { - "name": "Ignore Mood Warning Popup", + "name": "Ignore Low Morale Warning", "help": "Force through the mood warning popup when it appears." }, "AllowHighFlagshipLevel": { - "name": "Only replace the full-level rear row", - "help": "After opening, only level 100 white-skin light aircraft will be selected when changing flagships.\nPlease make sure there are qualified ships in the dock when using it." + "name": "Only Replace Max-Level Back Row", + "help": "When the flagship has a replacement and its level is higher than the vanguard\nUse white-rarity light carriers in the back row to consume oil and earn experience\nIf the back row has other ships, this function will not be used\nPlease manually add ships that need leveling" }, "AllowLowVanguardLevel": { - "name": "Replaceable low-level front row", + "name": "Allow Low-Level Vanguard Replacement", "help": "After opening, the front row level limit is changed to level 30 or above. If it is difficult mode (such as the CD picture), it will be changed to level 70." }, "DelayTaskIFNoFlagship": { - "name": "Postpone mission when there is no replacement flagship", + "name": "Delay Task When No Replacement Flagship Available", "help": "When changing the flagship, if a white aircraft carrier that meets the conditions cannot be found, the mission will be postponed." }, "CommissionLimit": { - "name": "Prevent the number of emergency commissions from being too large", + "name": "Prevent Too Many Urgent Commissions", "help": "To prevent the number of emergency orders from being too large and unable to complete daily orders when running 7x24, it is recommended to select only short-term high-yield orders in the order filter." }, "VanguardLevelMin": { @@ -1722,7 +1725,7 @@ "help": "" }, "PtLimit": { - "name": "Event PT Limit", + "name": "Stop When Event PT Reaches X", "help": "When accumulated PT reaches the given value, all event tasks are disabled. 0 means no limit. Cannot trigger immediately when using auto-search.\nAfter enabling, you only need to open all event tasks without counting total runs." }, "TimeLimit": { @@ -1760,7 +1763,7 @@ }, "StageFilter": { "name": "Event Daily Filter", - "help": "Sort by filter" + "help": "Sortie in filter order" }, "LastStage": { "name": "Last Completed Stage", @@ -1775,33 +1778,33 @@ "Mode": { "name": "Difficulty", "help": "", - "easy": "Simple", + "easy": "Easy", "normal": "Normal", "hard": "Hard", "ex": "EX" }, "UseTicket": { - "name": "Use practice tickets", + "name": "Use Exercise Tickets", "help": "" } }, "RaidScuttle": { "_info": { - "name": "Fight together and sink the ship", - "help": "" + "name": "Raid Scuttle", + "help": "Raid event scuttle settings. Select the position of ships to be sunk." }, "Sacrifice": { "name": "Sacrifice ship position", "help": "", - "vanguard": "front row", + "vanguard": "Vanguard", "flagship": "flagship", - "vanguard_flagship": "Front row + flagship" + "vanguard_flagship": "Vanguard + Flagship" } }, "RaidDaily": { "_info": { - "name": "Fighting together every day", - "help": "Complete daily battles on easy and normal difficulty" + "name": "Raid Daily", + "help": "Complete daily Raid on Easy, Normal, and Hard difficulties" }, "StageFilter": { "name": "Difficulty filter", @@ -1810,7 +1813,7 @@ }, "Hospital": { "_info": { - "name": "Activity settings", + "name": "Event Settings", "help": "" }, "UseRecommendFleet": { @@ -1820,15 +1823,15 @@ }, "HospitalEvent": { "_info": { - "name": "Co-fighting activity settings", + "name": "Hospital Event Settings", "help": "" }, "Mode": { "name": "Difficulty", "help": "", - "easy": "Easy", - "normal": "Normal", - "hard": "Hard" + "easy": "Noon", + "normal": "Dusk", + "hard": "Night" }, "Stage": { "name": "Level name", @@ -1842,17 +1845,17 @@ }, "MaritimeEscort": { "_info": { - "name": "merchant ship escort", - "help": "If you enter and then retreat, you can get about 70% of the reward." + "name": "Merchant Escort", + "help": "Enter and immediately retreat, can earn about 70% of the rewards" }, "Enable": { - "name": "Enable merchant ship escort", + "name": "Enable Merchant Escort", "help": "" } }, "Coalition": { "_info": { - "name": "Activity settings", + "name": "Coalition Settings", "help": "Teams must be manually allocated in the game before going out." }, "Mode": { @@ -1865,7 +1868,7 @@ "ex": "EX" }, "Fleet": { - "name": "attack team", + "name": "Sortie Fleet", "help": "", "single": "Single team battle", "multi": "Multiple teams attack" @@ -1873,15 +1876,15 @@ }, "CoalitionScuttle": { "_info": { - "name": "CoalitionScuttle._info.name", - "help": "CoalitionScuttle._info.help" + "name": "Coalition Scuttle", + "help": "Coalition event scuttle settings. Select the position of ships to be sunk." }, "Sacrifice": { - "name": "CoalitionScuttle.Sacrifice.name", - "help": "CoalitionScuttle.Sacrifice.help", - "vanguard": "vanguard", - "flagship": "flagship", - "vanguard_flagship": "vanguard_flagship" + "name": "Sacrificial Ship Position", + "help": "Select the position of ships to be sunk: Vanguard, Flagship, or Vanguard + Flagship", + "vanguard": "Vanguard", + "flagship": "Flagship", + "vanguard_flagship": "Vanguard + Flagship" } }, "EventShop": { @@ -1917,25 +1920,25 @@ "help": "" }, "PresetFilter": { - "name": "delegate filter", - "help": "If you do not have enough understanding of the output and benefits of the commission, it is not recommended to write a custom filter. It is recommended to use a pre-optimized commission filter.\nIf you run the brush emergency commission function 24 hours a day, you should use the filter (7x24 brush commission).\nIt is not recommended to use the oil priority filter while running the emergency commission function.", + "name": "Commission Filter", + "help": "Select a preset commission filter or enter a custom filter", "cube": "Diamond>Cubes>Oil", - "cube_24h": "Diamond > Cubes > Oil (7x24 brush commission)", - "chip": "Diamond>Mind>Cubes", - "chip_24h": "Diamond > Mind > Cubes (7x24 brush commission)", - "oil": "Diamond>Oil>Cubes", + "cube_24h": "Diamond > Cubes > Oil (7x24 Commission Farming)", + "chip": "Diamond > Cognitive Chips > Cubes", + "chip_24h": "Diamond > Cognitive Chips > Cubes (7x24 Commission Farming)", + "oil": "Diamond > Oil > Cubes", "custom": "Customize" }, "AddShortest": { "name": "Automatically choose the shortest daily commission", - "help": "Choose the shortest daily commission when there are not enough commissions in your custom delegate filter." + "help": "Automatically accept the shortest daily commission, excluding urgent commissions. Has lower priority than the commission filter" }, "CustomFilter": { - "name": "Custom delegate filter", - "help": "To use a custom filter, you need to set \"Delegated Filter\" to \"Custom\" and read https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn" + "name": "Custom Commission Filter", + "help": "Custom commission filter syntax is the same as the Commission Preset filter" }, "DoMajorCommission": { - "name": "Make the main commission (1200 oil/1000 oil commission)", + "name": "Run Major Commissions (1200/1000 Oil)", "help": "The 1200 oil commission is now obsolete and the yield is very low. It is recommended to close it." }, "CommissionNotifyReward": { @@ -1974,33 +1977,33 @@ }, "ControlExpOverflow": { "_info": { - "name": "Overflow experience control", + "name": "Overflow EXP Control", "help": "Prevent the use of advanced skill books from overflowing a large amount of experience when skills are almost at full level.\nFor example, if the T3 book overflows by 100 points and the T4 book overflows by 100 points, when there are 1100 experience points left, the T4 book will not be used and the T3 book will be used instead." }, "Enable": { - "name": "Enable overflow experience control", + "name": "Enable Overflow EXP Control", "help": "" }, "T4Allow": { - "name": "Allow T4 skill book to overflow X experience points", + "name": "Allow T4 Skill Book to Overflow X EXP", "help": "" }, "T3Allow": { - "name": "Allow T3 skill book to overflow X experience points", + "name": "Allow T3 Skill Book to Overflow X EXP", "help": "" }, "T2Allow": { - "name": "Allow T2 skill book to overflow X experience points", + "name": "Allow T2 Skill Book to Overflow X EXP", "help": "" }, "T1Allow": { - "name": "Allow T1 skill book to overflow X experience points", + "name": "Allow T1 Skill Book to Overflow X EXP", "help": "" } }, "AddNewStudent": { "_info": { - "name": "learn new skills", + "name": "Learn New Skills", "help": "When there is a vacant position in the Tactics Academy, a ship girl will be automatically assigned and will learn the first skill that is not yet full level." }, "Enable": { @@ -2012,7 +2015,7 @@ "help": "During student selection apply the favorite filter\nThereby, giving priority to certain ship girls\nApply the favorite status to a ship girl in-game" }, "MinLevel": { - "name": "Add Students with Level >= X Only", + "name": "Only Add Characters Level >= X", "help": "" } }, @@ -2094,7 +2097,7 @@ }, "Dorm": { "_info": { - "name": "Backyard", + "name": "Dorm", "help": "" }, "Collect": { @@ -2117,7 +2120,7 @@ "BuyFurniture": { "_info": { "name": "Event furniture setup", - "help": "Purchase limited-time furniture from the Backyard Furniture Shop during events" + "help": "Purchase limited-time furniture from the Dorm furniture shop during events" }, "Enable": { "name": "Buy event furniture", @@ -2140,7 +2143,7 @@ "help": "" }, "BuyAmount": { - "name": "Buy X cat boxes every day", + "name": "Buy X Meowfficer Boxes Daily", "help": "The first cat box of the day is free" }, "FortChoreMeowfficer": { @@ -2158,7 +2161,7 @@ "help": "" }, "Mode": { - "name": "Training mode", + "name": "Select Training Mode", "help": "Seamless Meowfficer Training will collect Meowfficers every 2.5 ~ 3.5 hours.\nCollect one per day means collect one every day, collect all on Sunday, enhance and refill the queue to complete weekly missions", "seamlessly": "Seamless training", "once_a_day": "Collect one per day" @@ -2272,7 +2275,7 @@ }, "GeneralShop": { "_info": { - "name": "General Shop", + "name": "Arms Dealer (General Shop)", "help": "" }, "Enable": { @@ -2285,15 +2288,15 @@ }, "Refresh": { "name": "Refresh store", - "help": "Refreshing requires 50 diamonds" + "help": "Refreshing costs 50 Gems" }, "BuySkinBox": { - "name": "Buy appearance equipment box", + "name": "Buy Skins Equipment Box", "help": "To reduce maintenance costs, the appearance box selector in the filter will not be updated with the game, but AzurPilot will treat any unknown item with a quantity of 1 and consuming 7000 supplies as an appearance box and purchase it." }, "ConsumeCoins": { - "name": "Consumption of overflow supplies", - "help": "When supplies exceed the threshold, purchase all items that consume supplies. 0 means disabled. Range: 1-600000" + "name": "Spend Overflowing Coins", + "help": "When coins exceed the limit, auto-purchase items from the Arms Dealer. Stops when coins drop below the limit" }, "OverflowCoins": { "name": "Buy Meowfficer Boxes on Coin Overflow", @@ -2497,7 +2500,7 @@ }, "Refresh": { "name": "Refresh Shop", - "help": "Refreshing costs 20 gems" + "help": "Refreshing costs 20 Gems" }, "Filter": { "name": "Shop Filter", @@ -2533,7 +2536,7 @@ "6": "PR6" }, "ShipIndex": { - "name": "Ship index", + "name": "Select Research Ship X", "help": "Counting from left to right, starting from 1", "0": "Don't buy", "1": "1", @@ -2568,7 +2571,7 @@ "6": "PR6" }, "ShipIndex": { - "name": "Ship index", + "name": "Select Research Ship X", "help": "Counting from left to right, starting from 1", "0": "Don't buy", "1": "1", @@ -2589,8 +2592,8 @@ }, "Gacha": { "_info": { - "name": "Daily Construction", - "help": "Build ships to complete daily missions. Building once in the Light pool daily saves 200 Coins." + "name": "Daily Build", + "help": "Build ships to complete daily missions. Building once in the Light Build Pool daily saves 200 Coins." }, "Pool": { "name": "Build Pool", @@ -2602,7 +2605,7 @@ "wishing_well": "Wishing Well" }, "Amount": { - "name": "Number of builds", + "name": "Build X Times Daily", "help": "", "1": "1", "2": "2", @@ -2616,12 +2619,12 @@ "10": "10" }, "UseTicket": { - "name": "Use single build permit", + "name": "Use Single Build Permit", "help": "When enabled, pools that support build permits will use them first. When no permits are available or building in a standard pool, Cubes will be used instead." }, "UseDrill": { - "name": "Use Quick Finisher to complete construction", - "help": "If set to No, ships will be launched tomorrow instead, saving one Quick Finisher." + "name": "Use Quick Finisher to Complete Build", + "help": "If set to No, will wait until tomorrow to launch. Use this to save Quick Finishers" } }, "BattlePass": { @@ -2703,7 +2706,7 @@ }, "PrivateQuarters": { "_info": { - "name": "Dorm settings", + "name": "Private Quarters Settings", "help": "" }, "BuyRoses": { @@ -2918,7 +2921,7 @@ "help": "Recommended 1.0 ~ 3.0, because the character portrait may cover the HP bar, so a brief confirmation period is needed." }, "OpponentRefreshValue": { - "name": "Exercise opponents refreshed X times today", + "name": "Opponents Refreshed X Times Today", "help": "Automatically updated value, resets at midnight.\nIf you manually refresh opponents, you should update this value." }, "OpponentRefreshRecord": { @@ -2950,7 +2953,7 @@ "help": "Beacon assist three times a day" }, "Tier": { - "name": "Find beacons with level greater than X", + "name": "Find Beacons with Level Above X", "help": "If you try several times and still can't find a beacon that meets the criteria, fight a random one." } }, @@ -2964,7 +2967,7 @@ "help": "Every time you enter the storage, use all Coordinate Loggers" }, "BuyActionPointLimit": { - "name": "Buy Action Points X times", + "name": "Buy Action Points X Times", "help": "Maximum number of times to buy Action Points", "0": "Don't buy", "1": "1 time (1000 Oil, 100 AP)", @@ -2991,7 +2994,7 @@ }, "RepairPackThresholdHazard1": { "name": "Use Repair Toolkit when any ship HP is below X (Corrosion 1)", - "help": "Only takes effect when \"Use Repair Toolkit to repair ships\" is enabled.\nOnly attempts repair during Corrosion 1 Leveling+.\n0.0 ~ 1.0" + "help": "Only takes effect when \"Use Repair Toolkit to repair ships\" is enabled.\nOnly attempts repair during Corrosion 1 Leveling Plus.\n0.0 ~ 1.0" }, "DoRandomMapEvent": { "name": "Complete random map events", @@ -3010,12 +3013,12 @@ "help": "Send Operation Siren notifications to the launcher and Windows notification channel. Disabling this only affects launcher notifications and does not affect OnePush." }, "IndependentPush": { - "name": "Independent push of Siren Operation information", - "help": "The \"Push Siren Operation Related Information\" function needs to be enabled simultaneously.\nTo disable it, navigate to Smart Port Settings > Debug Settings > Error Push Settings, and the \"Error Push\" function needs to be enabled simultaneously.\nTo enable it, use the Siren Action Push Settings. If you need to separate the pushed content, please enable this function" + "name": "Independent Operation Siren Push", + "help": "When enabled, Operation Siren notifications will be pushed via the Operation Siren push provider below\nWhen disabled, notifications will be pushed via the Error provider\nBefore enabling, please enable push notifications for individual events, such as: Corrosion 1, Meowfficer Farming, etc." }, "OpsiOnePushConfig": { "name": "Operation Siren notifications push settings", - "help": "The settings will take effect only when the \"Siren Operation Information Independent Push\" function is enabled. Use Onepush to push a message about Corrosion 1 and Short Cat. See the document for configuration method: https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/Onepush-configuration-%5BCN%5D" + "help": "Push settings for Operation Siren notifications\nWhen Operation Siren push is enabled, notifications will only be sent if this option is enabled\nOperation Siren push settings allow setting different OnePush providers from error notifications\nWhen the independent push is disabled, notifications will be pushed via the error notification provider" }, "AutoSearchTimeLimit": { "name": "Auto Search Time Limit", @@ -3085,7 +3088,7 @@ }, "OpsiExplore": { "_info": { - "name": "Operation Siren Explore", + "name": "Monthly Exploration Plus", "help": "Explore all sea zones in Operation Siren at the beginning of each month, without needing to buy a Campaign Information Recorder (5000 Oil item).\nThis function will clear a sea zone every 27 minutes to ensure airspace search cooldown.\nThe following conditions must be met before using this feature:\n- Complete Operation Siren main story and finish Simulation Battle + Siren Proving Ground" }, "SpecialRadar": { @@ -3111,7 +3114,7 @@ }, "OpsiShop": { "_info": { - "name": "Operation Siren Shop", + "name": "Operation Siren Shop Plus", "help": "The following conditions must be met before using this feature:\n- Complete Operation Siren main story and finish Simulation Battle + Siren Proving Ground\n- Enable Operation Siren exploration or use Campaign Information Recorder (5000 Oil item)\n- The month after completing Operation Siren main story, i.e. when the Port Shop becomes the new version." }, "PresetFilter": { @@ -3128,7 +3131,7 @@ "help": "To use a custom filter, set \"Port Shop Filter\" to \"Custom\" and read https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn" }, "DisableBeforeDate": { - "name": "Do not purchase from Operation Siren Shop before the Xth day of each month", + "name": "Do not purchase from OpSi Shop Plus before the Xth day of each month", "help": "Starting from the day after the specified date, buy from Operation Siren Shop once a day.\n0 means no limit, buy every day." } }, @@ -3144,11 +3147,11 @@ }, "OpsiDaily": { "_info": { - "name": "Operation Siren Daily", + "name": "Operation Siren Daily Plus", "help": "The following conditions must be met before using this feature:\n- Clear the Operation Siren main storyline and complete Simulation Battle + Siren Proving Ground\n- Enable Operation Siren Exploration mission or use the Campaign Information Recorder (5000 Oil item)" }, "DoMission": { - "name": "Do Operation Siren dailies", + "name": "Do Operation Siren Daily Plus", "help": "Pick up dailies at port and complete all dailies" }, "UseTuningSample": { @@ -3210,8 +3213,8 @@ "help": "When enabled, it will consume additional Action Points. Generally not needed, just run it slowly." }, "HasStronghold": { - "name": "Has Stronghold", - "help": "Whether a Siren Fortress is available on the map." + "name": "Siren Stronghold Available", + "help": "Execute when a Siren Stronghold is detected on the map" } }, "OpsiMonthBoss": { @@ -3412,11 +3415,11 @@ }, "OpsiScheduling": { "_info": { - "name": "Smart Scheduling", + "name": "Smart Scheduling Plus", "help": "Operation Siren smart scheduling function, used to coordinate switching between Corrosion 1 Leveling and yellow coin replenishment tasks.\nOnce enabled, it will automatically switch between different tasks and send push notifications based on coins and Action Points.\n\nSome safety restrictions of the original AzurPilot have been lifted, please set parameters carefully" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "Obtain X Combat Supply Vouchers", + "name": "Reserve X Operation Supply Tokens", "help": "When enabled, will use the 'Obtain X Combat Supply Vouchers' configuration below. When disabled, will use the Corrosion 1 Leveling yellow coin retention configuration.\nWhen disabled, Action Points are used for control.\nWhen enabled, yellow coins are used for control.", "True": "Enabled", "False": "Disabled" @@ -3430,7 +3433,7 @@ "help": "Stop when Action Points are lower than X, and automatically open AP boxes. X includes AP in boxes. It is recommended to preserve 1000 AP for Corrosion 1 Leveling." }, "OperationCoinsReturnThreshold": { - "name": "Obtain X Combat Supply Vouchers", + "name": "Return When Supply Tokens Below X", "help": "During the yellow coin acquisition task, obtain (increase) X Combat Supply Vouchers in total, then return to Corrosion 1 Leveling." }, "EnableMeowfficerFarming": { @@ -3460,6 +3463,10 @@ "TaskPriority": { "name": "Replenishment Task Priority", "help": "The priority order of tasks for yellow coin replenishment when they are insufficient. Separated by >." + }, + "APreserveUntilReset": { + "name": "Force Consume Action Points at Month-End Reset", + "help": "Auto-set reserved action points to 0 and consume remaining action points before Operation Siren reset\nRecommended to use with Corrosion 1 Leveling" } }, "OpsiPreventActionPointOverflow": { @@ -3691,7 +3698,7 @@ "help": "" }, "Positions": { - "name": "Position Count", + "name": "Assign X Miner Positions", "help": "", "1": "1", "2": "2", @@ -3738,7 +3745,7 @@ "help": "" }, "Positions": { - "name": "Position Count", + "name": "Assign X Lumberjack Positions", "help": "", "1": "1", "2": "2", @@ -3787,7 +3794,7 @@ "help": "Meals are produced sequentially from 1 to 8. The production target is the Meal X quantity option. The next item is only executed after the previous one is fully satisfied. Later items will not affect the completion of earlier items during runtime.\nDouble Bamboo Shoots and Asparagus Shrimp in meals 1-8 will automatically switch to corresponding seasonal meals based on the current season." }, "PostNumber": { - "name": "Position Count", + "name": "Assign X Chef Positions", "help": "", "1": "1", "2": "2" @@ -3818,7 +3825,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber1": { - "name": "Meal 1 Quantity", + "name": "Produce Meal 1 to X Servings", "help": "" }, "Meal2": { @@ -3839,7 +3846,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber2": { - "name": "Meal 2 Quantity", + "name": "Produce Meal 2 to X Servings", "help": "" }, "Meal3": { @@ -3860,7 +3867,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber3": { - "name": "Meal 3 Quantity", + "name": "Produce Meal 3 to X Servings", "help": "" }, "Meal4": { @@ -3881,7 +3888,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber4": { - "name": "Meal 4 Quantity", + "name": "Produce Meal 4 to X Servings", "help": "" }, "Meal5": { @@ -3902,7 +3909,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber5": { - "name": "Meal 5 Quantity", + "name": "Produce Meal 5 to X Servings", "help": "" }, "Meal6": { @@ -3923,7 +3930,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber6": { - "name": "Meal 6 Quantity", + "name": "Produce Meal 6 to X Servings", "help": "" }, "Meal7": { @@ -3944,7 +3951,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber7": { - "name": "Meal 7 Quantity", + "name": "Produce Meal 7 to X Servings", "help": "" }, "Meal8": { @@ -3965,7 +3972,7 @@ "onion_fish": "Steamed Onion Fish" }, "MealNumber8": { - "name": "Meal 8 Quantity", + "name": "Produce Meal 8 to X Servings", "help": "" } }, @@ -3998,7 +4005,7 @@ "help": "Meals are produced sequentially from 1 to 8. The production target is the Meal X quantity option. The next item is only executed after the previous one is fully satisfied. Later items will not affect the completion of earlier items during runtime.\nPlease craft honey products manually.\nSpring Flower Tea and Fresh Pineapple Juice in meals will automatically switch to corresponding seasonal meals based on the current season." }, "PostNumber": { - "name": "Position Count", + "name": "Assign X Bartender Positions", "help": "", "1": "1", "2": "2" @@ -4027,7 +4034,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber1": { - "name": "Meal 1 Quantity", + "name": "Produce Meal 1 to X Servings", "help": "" }, "Meal2": { @@ -4046,7 +4053,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber2": { - "name": "Meal 2 Quantity", + "name": "Produce Meal 2 to X Servings", "help": "" }, "Meal3": { @@ -4065,7 +4072,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber3": { - "name": "Meal 3 Quantity", + "name": "Produce Meal 3 to X Servings", "help": "" }, "Meal4": { @@ -4084,7 +4091,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber4": { - "name": "Meal 4 Quantity", + "name": "Produce Meal 4 to X Servings", "help": "" }, "Meal5": { @@ -4103,7 +4110,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber5": { - "name": "Meal 5 Quantity", + "name": "Produce Meal 5 to X Servings", "help": "" }, "Meal6": { @@ -4122,7 +4129,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber6": { - "name": "Meal 6 Quantity", + "name": "Produce Meal 6 to X Servings", "help": "" }, "Meal7": { @@ -4141,7 +4148,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber7": { - "name": "Meal 7 Quantity", + "name": "Produce Meal 7 to X Servings", "help": "" }, "Meal8": { @@ -4160,7 +4167,7 @@ "spring_flower_tea": "Spring Flower Tea" }, "MealNumber8": { - "name": "Meal 8 Quantity", + "name": "Produce Meal 8 to X Servings", "help": "" } }, @@ -4191,7 +4198,7 @@ "help": "Meals are produced sequentially from 1 to 8. The production target is the Meal X quantity option. The next item is only executed after the previous one is fully satisfied. Later items will not affect the completion of earlier items during runtime." }, "PostNumber": { - "name": "Position Count", + "name": "Assign X Grill Chef Positions", "help": "", "1": "1", "2": "2" @@ -4214,7 +4221,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber1": { - "name": "Meal 1 Quantity", + "name": "Produce Meal 1 to X Servings", "help": "" }, "Meal2": { @@ -4231,7 +4238,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber2": { - "name": "Meal 2 Quantity", + "name": "Produce Meal 2 to X Servings", "help": "" }, "Meal3": { @@ -4248,7 +4255,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber3": { - "name": "Meal 3 Quantity", + "name": "Produce Meal 3 to X Servings", "help": "" }, "Meal4": { @@ -4265,7 +4272,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber4": { - "name": "Meal 4 Quantity", + "name": "Produce Meal 4 to X Servings", "help": "" }, "Meal5": { @@ -4282,7 +4289,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber5": { - "name": "Meal 5 Quantity", + "name": "Produce Meal 5 to X Servings", "help": "" }, "Meal6": { @@ -4299,7 +4306,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber6": { - "name": "Meal 6 Quantity", + "name": "Produce Meal 6 to X Servings", "help": "" }, "Meal7": { @@ -4316,7 +4323,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber7": { - "name": "Meal 7 Quantity", + "name": "Produce Meal 7 to X Servings", "help": "" }, "Meal8": { @@ -4333,7 +4340,7 @@ "double_energy": "Double Energy Combo" }, "MealNumber8": { - "name": "Meal 8 Quantity", + "name": "Produce Meal 8 to X Servings", "help": "" } }, @@ -4362,7 +4369,7 @@ "help": "Meals are produced sequentially from 1 to 8. The production target is the Meal X quantity option. The next item is only executed after the previous one is fully satisfied. Later items will not affect the completion of earlier items during runtime." }, "PostNumber": { - "name": "Position Count", + "name": "Assign X Cook Positions", "help": "", "1": "1", "2": "2" @@ -4387,7 +4394,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber1": { - "name": "Meal 1 Quantity", + "name": "Produce Meal 1 to X Servings", "help": "" }, "Meal2": { @@ -4406,7 +4413,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber2": { - "name": "Meal 2 Quantity", + "name": "Produce Meal 2 to X Servings", "help": "" }, "Meal3": { @@ -4425,7 +4432,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber3": { - "name": "Meal 3 Quantity", + "name": "Produce Meal 3 to X Servings", "help": "" }, "Meal4": { @@ -4444,7 +4451,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber4": { - "name": "Meal 4 Quantity", + "name": "Produce Meal 4 to X Servings", "help": "" }, "Meal5": { @@ -4463,7 +4470,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber5": { - "name": "Meal 5 Quantity", + "name": "Produce Meal 5 to X Servings", "help": "" }, "Meal6": { @@ -4482,7 +4489,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber6": { - "name": "Meal 6 Quantity", + "name": "Produce Meal 6 to X Servings", "help": "" }, "Meal7": { @@ -4501,7 +4508,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber7": { - "name": "Meal 7 Quantity", + "name": "Produce Meal 7 to X Servings", "help": "" }, "Meal8": { @@ -4520,7 +4527,7 @@ "seafood_rice": "Seafood Rice" }, "MealNumber8": { - "name": "Meal 8 Quantity", + "name": "Produce Meal 8 to X Servings", "help": "" } }, @@ -4551,7 +4558,7 @@ "help": "Meals are produced sequentially from 1 to 8. The production target is the Meal X quantity option. The next item is only executed after the previous one is fully satisfied. Later items will not affect the completion of earlier items during runtime." }, "PostNumber": { - "name": "Position Count", + "name": "Assign X Barista Positions", "help": "", "1": "1", "2": "2" @@ -4579,7 +4586,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber1": { - "name": "Meal 1 Quantity", + "name": "Produce Meal 1 to X Servings", "help": "" }, "Meal2": { @@ -4597,7 +4604,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber2": { - "name": "Meal 2 Quantity", + "name": "Produce Meal 2 to X Servings", "help": "" }, "Meal3": { @@ -4615,7 +4622,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber3": { - "name": "Meal 3 Quantity", + "name": "Produce Meal 3 to X Servings", "help": "" }, "Meal4": { @@ -4633,7 +4640,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber4": { - "name": "Meal 4 Quantity", + "name": "Produce Meal 4 to X Servings", "help": "" }, "Meal5": { @@ -4651,7 +4658,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber5": { - "name": "Meal 5 Quantity", + "name": "Produce Meal 5 to X Servings", "help": "" }, "Meal6": { @@ -4669,7 +4676,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber6": { - "name": "Meal 6 Quantity", + "name": "Produce Meal 6 to X Servings", "help": "" }, "Meal7": { @@ -4687,7 +4694,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber7": { - "name": "Meal 7 Quantity", + "name": "Produce Meal 7 to X Servings", "help": "" }, "Meal8": { @@ -4705,7 +4712,7 @@ "fruity_fruitier": "Fruity Fruitier" }, "MealNumber8": { - "name": "Meal 8 Quantity", + "name": "Produce Meal 8 to X Servings", "help": "" } }, @@ -4897,7 +4904,7 @@ "False": "Disabled" }, "SeasonalThreshold": { - "name": "Seasonal Threshold", + "name": "Replace Seasonal Items When Below X", "help": "Replace seasonal dish when stock is below this number" } }, @@ -5569,7 +5576,7 @@ }, "Daemon": { "_info": { - "name": "semi-automatic click", + "name": "Semi-Automatic Click", "help": "It is useful when attacking unadapted levels, which can reduce a lot of manual operations.\nAzurPilot will help you click to prepare for battle, click to settle the battle, skip the plot, automatically retire, and close various pop-up windows. You just need to select the enemy manually." }, "EnterMap": { @@ -5639,7 +5646,7 @@ "help": "Current scaffold: dock, first ship, quick change, equipping filter, placeholder strategy, next ship." }, "ShipLimit": { - "name": "Ship limit", + "name": "Process First X Ships", "help": "0 means continue until no next ship is detected. Use 1 or another small value while debugging." }, "EnableSlot1": { @@ -5679,7 +5686,7 @@ "help": "Test which screenshot scheme and control scheme is faster, speed varies based on emulator and computer configuration\nAfter the test is completed, select the fastest screenshot and control scheme in \"AzurPilot\" - \"Emulator Settings\"" }, "DeviceType": { - "name": "Device type", + "name": "Select Device Type", "help": "", "emulator": "emulator", "plone_cloud_with_adb": "Cloud mobile phone has public network ADB", @@ -5688,7 +5695,7 @@ "android_phone_vmos": "Android real machine VMOS virtual machine" }, "TestScene": { - "name": "test scenario", + "name": "Test Scene", "help": "", "screenshot_click": "Screenshot + click speed test", "screenshot": "Screenshot speed test", @@ -5809,7 +5816,7 @@ }, "BuyAp": { "name": "Weekly AP Purchase", - "help": "Whether to purchase 1000 action points weekly. Fortress will still be fought even without purchasing." + "help": "Whether to purchase 1000 action points weekly. Stronghold will still be fought even without purchasing." }, "Cl1Coin": { "name": "Corrosion 1 Coin Expectation", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index d8bc61191..cd105eb6c 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -1,15 +1,15 @@ { "Menu": { "Alas": { - "name": "スマートポートエリア+", + "name": "スマートポートPlus", "help": "" }, "Farm": { - "name": "出撃+", + "name": "出撃Plus", "help": "" }, "Event": { - "name": "イベント+", + "name": "イベントPlus", "help": "" }, "EventDaily": { @@ -25,7 +25,7 @@ "help": "" }, "Opsi": { - "name": "セイレーン作戦+", + "name": "セイレーン作戦Plus", "help": "" }, "Island": { @@ -33,7 +33,7 @@ "help": "" }, "Tool": { - "name": "ツール+", + "name": "ツールPlus", "help": "" } }, @@ -51,23 +51,23 @@ "help": "" }, "Main": { - "name": "メインステージ-1+", + "name": "メインステージ-1Plus", "help": "" }, "Main2": { - "name": "メインステージ-2+", + "name": "メインステージ-2Plus", "help": "" }, "Main3": { - "name": "メインステージ+", + "name": "メインステージ-3Plus", "help": "" }, "GemsFarming": { - "name": "緊急委託+", + "name": "緊急委託Plus", "help": "" }, "ThreeOilLowCost": { - "name": "3燃料低コスト+", + "name": "3燃料低コストPlus", "help": "3燃料低コスト編成で戦闘し、旗艦がレベル32に達すると自動的に旗艦を交換して退役させます。" }, "Ambush11": { @@ -79,23 +79,23 @@ "help": "" }, "Event": { - "name": "イベントマップ-1+", + "name": "イベントマップ-1Plus", "help": "" }, "Event2": { - "name": "イベントマップ-2+", + "name": "イベントマップ-2Plus", "help": "" }, "Event3": { - "name": "イベントマップ-3+", + "name": "イベントマップ-3Plus", "help": "" }, "Raid": { - "name": "レイドイベント+", + "name": "レイドイベントPlus", "help": "" }, "RaidScuttle": { - "name": "レイド沈船+", + "name": "レイド沈船Plus", "help": "" }, "Hospital": { @@ -107,8 +107,8 @@ "help": "" }, "CoalitionScuttle": { - "name": "Task.CoalitionScuttle.name", - "help": "Task.CoalitionScuttle.help" + "name": "連合沈船Plus", + "help": "連合イベントで艦船が撃沈された後に好感度を周回します。複数艦隊での出撃を推奨。第1艦隊で沈めます。関連チュートリアルをご確認ください。" }, "MaritimeEscort": { "name": "商船の護衛", @@ -159,7 +159,7 @@ "help": "" }, "Research": { - "name": "研究", + "name": "科研Plus", "help": "" }, "Dorm": { @@ -211,7 +211,7 @@ "help": "" }, "Freebies": { - "name": "無料報酬", + "name": "無料特典Plus", "help": "" }, "Minigame": { @@ -219,7 +219,7 @@ "help": "ゲームコインがオーバーフローしない場合に自動的にゲームコインを受け取り、ゲームコインがあり報酬が満杯でない場合に自動的にミニゲームをプレイします。" }, "PrivateQuarters": { - "name": "寮計画", + "name": "ドルミトリープランPlus", "help": "" }, "OpsiGeneral": { @@ -235,11 +235,11 @@ "help": "" }, "OpsiExplore": { - "name": "月間開拓+", + "name": "月間開拓Plus", "help": "" }, "OpsiShop": { - "name": "セイレーン作戦ショップ+", + "name": "セイレーン作戦ショップPlus", "help": "" }, "OpsiVoucher": { @@ -247,7 +247,7 @@ "help": "" }, "OpsiDaily": { - "name": "セイレーン作戦-デイリー+", + "name": "セイレーン作戦デイリーPlus", "help": "" }, "OpsiObscure": { @@ -271,15 +271,15 @@ "help": "" }, "OpsiMeowfficerFarming": { - "name": "指揮ニャファーム+", + "name": "短猫相接", "help": "" }, "OpsiHazard1Leveling": { - "name": "侵蝕1レベリング+", + "name": "侵蝕1レベリング", "help": "" }, "OpsiScheduling": { - "name": "スマートスケジューリング+", + "name": "スマートスケジューリングPlus", "help": "セイレーン作戦スマートスケジューリング機能。侵蝕1レベリングとゴールドコイン補充タスク間の切り替えを調整します。\n有効にすると、ゴールドコインと行動力に基づいて異なるタスク間を自動的に切り替え、プッシュ通知を送信します。" }, "OpsiPreventActionPointOverflow": { @@ -575,7 +575,7 @@ "jp-17": "[JP] スイートバン" }, "ScreenshotMethod": { - "name": "エミュレータスクリーンショット方式", + "name": "スクリーンショット方式を選択", "help": "自動選択を使用すると、パフォーマンステストを実行し、最速のスクリーンショット方式に自動変更します\n通常の速度: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB\nツール - パフォーマンステストを実行して最速の方式を見つけてください", "auto": "最速を自動選択", "ADB": "ADB", @@ -589,7 +589,7 @@ "ldopengl": "ldopengl" }, "ControlMethod": { - "name": "エミュレータ制御方式", + "name": "コントロール方式を選択", "help": "速度: minitouch > Hermit >>> uiautomator2 ~= ADB\nminitouchを推奨。vmos下を除き、Hermitは非推奨。", "ADB": "ADB", "uiautomator2": "uiautomator2", @@ -618,7 +618,7 @@ "help": "以下の値はSerialに基づいて自動入力されます。理解できない場合は変更しないでください。ポートを変更すると、対応するインスタンスが自動的に起動します。" }, "Emulator": { - "name": "エミュレータの種類", + "name": "エミュレータタイプを選択", "help": "", "auto": "自動検出", "NoxPlayer": "NoxPlayer", @@ -696,7 +696,7 @@ "help": "機密タスク実行中にエラーが発生した場合、ゲームを再起動せずにAzurPilotを停止します\n機密タスクには隠秘海域、深淵海域、月跨ぎデイリーなどが含まれます" }, "SaveErrorCount": { - "name": "エラーログ保存上限", + "name": "エラーログは最大X件保存", "help": "上限を超えたエラーログは削除されます。0または負の値の場合、制限はありません。" }, "OnePushConfig": { @@ -712,7 +712,7 @@ "help": "ゲームフリーズ(GameStuckError発生)が指定回数に達した後、エミュレータの再起動を試みます。エミュレータのフリーズやメモリオーバーフローなどの問題の解決に役立つ場合があります。" }, "GameStuckThreshold": { - "name": "ゲームがスタックした再起動のしきい値", + "name": "ゲームフリーズからX秒後に再起動", "help": "再起動をトリガーする前にスタックが連続して何回発生するか。" }, "AdbOfflineRestart": { @@ -720,7 +720,7 @@ "help": "指定した回数連続してデバイスが検出できない(ADB がオフラインまたは接続に失敗する)場合は、エミュレータを再起動してみてください。" }, "AdbOfflineThreshold": { - "name": "デバイスの再起動しきい値が検出されない", + "name": "デバイス未検出からX秒後に再起動", "help": "デバイスが連続して検出できなかった回数に応じて、再起動がトリガーされます。" }, "LlmAnalysis": { @@ -934,7 +934,7 @@ "help": "" }, "RetireMode": { - "name": "退役モード", + "name": "退役モードを選択", "help": "強化を選択した場合、強化素材が不足するとワンクリック退役に戻ります。", "one_click_retire": "ワンクリック退役", "enhance": "強化", @@ -943,7 +943,7 @@ }, "PublicEmotion": { "_info": { - "name": "コンディション共有", + "name": "コンディション共用", "help": "同じ艦隊を使用して複数のタスクを完了できます\nこの機能を使用する場合は、対応するタスクの艦隊設定がすべての単独チームであり、その艦隊が共有艦隊であることを確認してください。" }, "Enable": { @@ -1024,7 +1024,7 @@ "help": "形式: \"cv > bb > ...\"\nデフォルトの強化方法を使用する場合は空白のままにしてください" }, "CheckPerCategory": { - "name": "艦種ごとの強化チェック数", + "name": "カテゴリごとにX隻確認", "help": "各艦種ごとに最大何隻まで強化チェックしますか?\n戦闘中の艦船はスキップされ、カウントされません。\n強化素材がある艦船を優先します" } }, @@ -1050,7 +1050,7 @@ "help": "" }, "RetireAmount": { - "name": "退役数量", + "name": "毎回X隻退役", "help": "", "retire_all": "全員退役", "retire_10": "10隻退役" @@ -1221,7 +1221,7 @@ "war_archives_20240725_cn": "檔案 夢幻の間奏曲" }, "Mode": { - "name": "ステージモード", + "name": "ステージモードを選択", "help": "メインステージのみに適用されます", "normal": "普通", "hard": "ハード" @@ -1304,7 +1304,7 @@ "help": "" }, "DailyRunCount": { - "name": "デイリー出撃回数", + "name": "毎日X回出撃", "help": "作戦アーカイブを1日に実行する最大回数です。\n0は無制限です。\n上限に達すると、タスクを無効化せず次回サーバー更新まで延期します。" }, "DailyRunCountRemain": { @@ -1326,7 +1326,7 @@ "help": "" }, "Fleet1": { - "name": "第一艦隊番号", + "name": "第一艦隊は第X艦隊を使用", "help": "", "1": "1 ", "2": "2 ", @@ -1343,7 +1343,7 @@ "diamond": "輪形陣" }, "Fleet1Mode": { - "name": "第一艦隊自律モード", + "name": "第一艦隊自律戦闘モード", "help": "", "combat_auto": "自動戦闘", "combat_manual": "手動操作", @@ -1360,7 +1360,7 @@ "5": "5 " }, "Fleet2": { - "name": "第二艦隊番号", + "name": "第二艦隊は第X艦隊を使用", "help": "", "0": "未使用", "1": "1 ", @@ -1378,7 +1378,7 @@ "diamond": "輪形陣" }, "Fleet2Mode": { - "name": "第二艦隊自律モード", + "name": "第二艦隊自律戦闘モード", "help": "", "combat_auto": "自動戦闘", "combat_manual": "手動操作", @@ -1403,8 +1403,8 @@ "fleet1_standby_fleet2_all": "第一艦隊待機 第二艦隊全滅" }, "SkipPreparation": { - "name": "Fleet.SkipPreparation.name", - "help": "Fleet.SkipPreparation.help" + "name": "編成チェックをスキップ", + "help": "有効にすると、編成画面の艦隊選択ステップをスキップし、現在のプリセット艦隊で直接出撃します。艦隊スロットが未解放のアカウントに適しています。" } }, "Submarine": { @@ -1413,14 +1413,14 @@ "help": "" }, "Fleet": { - "name": "潜水艦艦隊数", + "name": "第X潜水艦隊を使用", "help": "", "0": "未使用", "1": "1 ", "2": "2 " }, "Mode": { - "name": "潜水艦出撃方案", + "name": "潜水艦出撃プランを選択", "help": "自律索敵がオフの場合にのみ有効です。注意:「狩猟及びBOSS戦」は「狩猟のみ」と「BOSS戦のみ」の組み合わせです。道中で狩猟攻撃を実行し、BOSS戦で潜水艦を召喚しようとします。", "do_not_use": "未使用", "hunt_only": "狩猟のみ", @@ -1429,7 +1429,7 @@ "every_combat": "毎戦出撃" }, "AutoSearchMode": { - "name": "潜水艦自律方案", + "name": "潜水艦自律プランを選択", "help": "自律索敵の場合にのみ有効", "sub_standby": "待機", "sub_auto_call": "潜水艦を自動的に召喚する" @@ -1722,7 +1722,7 @@ "help": "" }, "PtLimit": { - "name": "イベントPT制限", + "name": "イベントPTがXに達したら停止", "help": "累積PTが指定値に達すると、すべてのイベントタスクが無効になります。0は制限なしを意味します。自律索敵使用時は即時発動できません。\n有効にすると、すべてのイベントタスクを開くだけで、合計何回出撃するかを計算する必要がありません。" }, "TimeLimit": { @@ -1740,7 +1740,7 @@ "help": "有効にすると、出撃ごとに資金量がチェックされます。" }, "CoinLimit": { - "name": "資金をXより多く保つ", + "name": "資金をX以上に維持", "help": "" }, "TaskCall": { @@ -1860,7 +1860,7 @@ "help": "SPステージはイベントデイリーSPで実行する必要があります", "easy": "簡単", "normal": "普通", - "hard": "困難", + "hard": "ハード", "sp": "SP", "ex": "EX" }, @@ -1873,15 +1873,15 @@ }, "CoalitionScuttle": { "_info": { - "name": "CoalitionScuttle._info.name", - "help": "CoalitionScuttle._info.help" + "name": "連合沈船", + "help": "連合イベント沈船設定。撃沈される艦船の位置を選択します。" }, "Sacrifice": { - "name": "CoalitionScuttle.Sacrifice.name", - "help": "CoalitionScuttle.Sacrifice.help", - "vanguard": "vanguard", - "flagship": "flagship", - "vanguard_flagship": "vanguard_flagship" + "name": "犠牲艦の位置", + "help": "撃沈される艦船の位置を選択します:前衛/旗艦/前衛+旗艦", + "vanguard": "前衛", + "flagship": "旗艦", + "vanguard_flagship": "前衛+旗艦" } }, "EventShop": { @@ -1982,19 +1982,19 @@ "help": "" }, "T4Allow": { - "name": "T4スキルブックのX経験値溢出を許可する", + "name": "T4スキルブックの経験値オーバーフローをX許可", "help": "" }, "T3Allow": { - "name": "T3スキルブックのX経験値溢出を許可する", + "name": "T3スキルブックの経験値オーバーフローをX許可", "help": "" }, "T2Allow": { - "name": "T2スキルブックのX経験値溢出を許可する", + "name": "T2スキルブックの経験値オーバーフローをX許可", "help": "" }, "T1Allow": { - "name": "T1スキルブックのX経験値溢出を許可する", + "name": "T1スキルブックの経験値オーバーフローをX許可", "help": "" } }, @@ -2012,7 +2012,7 @@ "help": "" }, "MinLevel": { - "name": "レベルがX以上のキャラクターのみを追加してください。", + "name": "レベルX以上のキャラクターのみ追加", "help": "" } }, @@ -2050,12 +2050,12 @@ "help": "資源が不足している場合、AzurPilotは条件が満たされるか科研キューが空になりそうになるまで科研を遅延させますが、科研が停止することはありません。\n長期間資源が不足している場合やAzurPilotの実行時間が十分でない場合は、このオプションをオフにして科研キューを常に満杯に保つことができます。" }, "AllowGenreT": { - "name": "T型研究を許可", - "help": "T型研究は研究キューの停止や効率低下を招く可能性があります。必要な場合のみ有効にしてください。" + "name": "T型科研を許可する", + "help": "T型科研" }, "RemainingCommissions": { - "name": "T型研究の残り委託回数", - "help": "保留中のT型研究に必要な残り委託回数です。-1は保留中のT型研究がないことを示します。" + "name": "T型科研の残り委託回数", + "help": "T型科研" }, "PresetFilter": { "name": "科研フィルター", @@ -2144,7 +2144,7 @@ "help": "毎日最初のネコハコは無料です" }, "FortChoreMeowfficer": { - "name": "ニャ窩インタラクション", + "name": "ニャコハウス互動", "help": "掃除、給食、ネコじゃらし" } }, @@ -2158,7 +2158,7 @@ "help": "" }, "Mode": { - "name": "訓練モード", + "name": "訓練モードを選択", "help": "連続訓練は2.5~3.5時間ごとに指揮ニャを受け取ります\n毎日1匹受け取りは毎日1匹受け取り、日曜日に全部受け取り、強化してキューを補充し、週間任務を完了します。", "seamlessly": "連続訓練", "once_a_day": "毎日1匹受け取り" @@ -2272,7 +2272,7 @@ }, "GeneralShop": { "_info": { - "name": "武器商人(雑貨店)", + "name": "軍火商(一般ショップ)", "help": "" }, "Enable": { @@ -2306,7 +2306,7 @@ }, "GuildShop": { "_info": { - "name": "フリートストア", + "name": "艦隊ショップ", "help": "" }, "Enable": { @@ -2395,7 +2395,7 @@ "plane": "艦載機" }, "PR1": { - "name": "一期研究設計図選択", + "name": "一期科研設計図選択", "help": "", "neptune": "ネプチューン", "monarch": "君主", @@ -2405,7 +2405,7 @@ "saintlouis": "サン・ルイ" }, "PR2": { - "name": "二期研究設計図選択", + "name": "二期科研設計図選択", "help": "", "seattle": "シアトル", "georgia": "ジョージア", @@ -2413,7 +2413,7 @@ "gascogne": "ガスコーニュ" }, "PR3": { - "name": "三期研究設計図選択", + "name": "三期科研設計図選択", "help": "", "cheshire": "チェシャー", "mainz": "マインツ", @@ -2520,20 +2520,20 @@ }, "ShipyardDr": { "_info": { - "name": "カラー科学研究図面購入設定", - "help": "カラー サイエンス図面を毎日購入します。カラー サイエンス図面とゴールド サイエンス図面の割引は個別にカウントされ、毎日それぞれ 2 枚を無料で購入できます。" + "name": "色彩科研設計図購入設定", + "help": "色彩科研設計図を毎日購入します。色彩科研と金科研の設計図は割引カウントが独立しており、毎日それぞれ2枚無料で入手できます。" }, "ResearchSeries": { - "name": "科学研究期間", + "name": "科研期数", "help": "", - "2": "フェーズ II 科学研究", - "3": "第Ⅲ相科学研究", - "4": "科学研究の第 4 段階", - "5": "科学研究の第 5 段階", - "6": "第6期科学研究" + "2": "フェーズ II 科研", + "3": "第Ⅲ期科研", + "4": "第4期科研", + "5": "第5期科研", + "6": "第6期科研" }, "ShipIndex": { - "name": "出荷シリアル番号", + "name": "科研船X番を選択", "help": "左から右に1から数えます", "0": "購入しない", "1": "1", @@ -2554,21 +2554,21 @@ }, "Shipyard": { "_info": { - "name": "ゴールド科学研究図面購入設定", + "name": "金科研設計図購入設定", "help": "" }, "ResearchSeries": { - "name": "科学研究期間", + "name": "科研期数", "help": "", - "1": "科学研究の第一段階", - "2": "フェーズ II 科学研究", - "3": "第Ⅲ相科学研究", - "4": "科学研究の第 4 段階", - "5": "科学研究の第 5 段階", - "6": "第6期科学研究" + "1": "第1期科研", + "2": "フェーズ II 科研", + "3": "第Ⅲ期科研", + "4": "第4期科研", + "5": "第5期科研", + "6": "第6期科研" }, "ShipIndex": { - "name": "出荷シリアル番号", + "name": "科研船X番を選択", "help": "左から右に1から数えます", "0": "購入しない", "1": "1", @@ -2589,20 +2589,20 @@ }, "Gacha": { "_info": { - "name": "毎日のカード抽選", - "help": "カードを引いて毎日のタスクを完了します。毎日ライトプールを引いて、200 個の物資を入手します。" + "name": "デイリー建造", + "help": "建造でデイリー任務を完了します。毎日軽型プールを1回引くことで資金200を節約できます。" }, "Pool": { "name": "カードプール", "help": "", - "light": "ライトプール", - "heavy": "ヘビープール", - "special": "特別なプール", - "event": "イベントプール", - "wishing_well": "願いのプール" + "light": "軽型プール", + "heavy": "重装型プール", + "special": "特装型プール", + "event": "期間限定プール", + "wishing_well": "祈願プール" }, "Amount": { - "name": "引いたカードの枚数", + "name": "毎日X回建造", "help": "", "1": "1", "2": "2", @@ -2616,17 +2616,17 @@ "10": "10" }, "UseTicket": { - "name": "単一の建設チケットを使用する", + "name": "単回建造券を使用する", "help": "オープン後、建設クーポンをサポートするプールは、最初に建設クーポンを使用して建設されます。建設クーポンがなく、通常のプールが建設された場合でも、ルービック キューブが使用されます。" }, "UseDrill": { - "name": "ドリルを使用して構築を完了します", - "help": "水に入るのを明日まで待ちますか?ドリルビットを欲しがることができますか?" + "name": "建造ドリルを使用して建造を完了する", + "help": "いいえにすると翌日まで進水を待つことで、ドリルを1つ節約できます。" } }, "BattlePass": { "_info": { - "name": "マンスリーパス", + "name": "シーズンパス", "help": "" }, "Collect": { @@ -2672,11 +2672,11 @@ }, "SupplyPack": { "_info": { - "name": "割引ギフトパッケージ", + "name": "お得なパッケージ", "help": "" }, "Collect": { - "name": "無料の週刊誌を購入する", + "name": "毎週無料パッケージを購入する", "help": "" }, "DayOfWeek": { @@ -2703,7 +2703,7 @@ }, "PrivateQuarters": { "_info": { - "name": "寮プランの設定", + "name": "ドルミトリープラン設定", "help": "" }, "BuyRoses": { @@ -2715,8 +2715,8 @@ "help": "ケーキギフトボックスの購入数は毎週月曜日にリセットされます\n実行するたびに、すべて一度に購入しようとします。ダイヤが不足している場合(ダイヤが210個未満)、購入はキャンセルされ、翌日に延期されます。" }, "TargetInteract": { - "name": "日々のエネルギーをクリアに", - "help": "選択した艦娘と対話して今日のエネルギーをクリアしよう" + "name": "毎日のコンディションを消費", + "help": "選択した艩娘と対話して今日のコンディションを消費します" }, "TargetShip": { "name": "毎日対話する艦娘を選択してください", @@ -2918,7 +2918,7 @@ "help": "キャラクターの立ち絵がHPバーを覆うため、HPが本当に閾値を下回っているか確認するのに時間がかかります。1.0~3.0を推奨します。" }, "OpponentRefreshValue": { - "name": "本日の演習対戦相手はX回リフレッシュされました", + "name": "本日対戦相手をX回リフレッシュ済み", "help": "値は自動的に更新され、0時以降にリセットされます\n対戦相手を手動で更新する場合は、この値を変更する必要があります" }, "OpponentRefreshRecord": { @@ -2928,11 +2928,11 @@ }, "Sos": { "_info": { - "name": "海底地図", + "name": "SOS/海底", "help": "" }, "Chapter": { - "name": "潜水艦攻撃の章", + "name": "潜水艦出撃チャプター", "help": "", "3": "3", "4": "4", @@ -2950,7 +2950,7 @@ "help": "1 日 3 回のビーコンサポート" }, "Tier": { - "name": "X より大きいレベルのビーコンを検索する", + "name": "レベルX以上のビーコンを検索", "help": "何度か試しても条件を満たすビーコンが見つからない場合は、ランダムに試してください。" } }, @@ -2964,8 +2964,8 @@ "help": "倉庫に入るたびにすべての座標レコーダーを使用する" }, "BuyActionPointLimit": { - "name": "作戦記録をX回購入", - "help": "作戦記録の購入最大回数", + "name": "行動力をX回購入", + "help": "行動力を購入する回数。1回目の購入で燃料1000、行動力100を消費します", "0": "購入しない", "1": "1回(燃料1000、作戦記録100)", "2": "2回(燃料2000、作戦記録200)", @@ -2974,7 +2974,7 @@ "5": "5回(燃料10000、作戦記録1000)" }, "OilLimit": { - "name": "作戦記録購入時、燃料Xを保留", + "name": "行動力購入時に燃料Xを保留", "help": "" }, "RepairThreshold": { @@ -2991,7 +2991,7 @@ }, "RepairPackThresholdHazard1": { "name": "いずれかの船のHPがX以下になったら、修理ボックスを使用して船を修理してください(侵蝕1)。", - "help": "「修理ボックスを使用して船を修理する」がオンの場合のみ有効です\n「侵蝕1レベリング+」中のみ修理を試みます。\n0.0~1.0" + "help": "「修理ボックスを使用して船を修理する」がオンの場合のみ有効です\n「侵蝕1レベリングPlus」中のみ修理を試みます。\n0.0~1.0" }, "DoRandomMapEvent": { "name": "マップ上のランダムイベントを完了する", @@ -3003,7 +3003,7 @@ }, "NotifyOpsiMail": { "name": "セイレーン作戦関連情報をプッシュ", - "help": "侵食1とショートキャットの情報を OnePush で通知します。ランチャー通知は「ランチャー通知」で個別に制御します。情報量が多いため騒がしい場合があります。" + "help": "侵蝕1と短猫相接の記録機能を使用する場合には、このオプションを有効にしてください" }, "LauncherPush": { "name": "ランチャー通知", @@ -3015,7 +3015,7 @@ }, "OpsiOnePushConfig": { "name": "セイレーン作戦プッシュ通知設定", - "help": "「塞壬行動信息獨立推送」功能開啟時設置才生效。Onepushを使用して、侵食1と短毛猫に関する情報を1つプッシュします。設定方法はドキュメントを参照してください:https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/Onepush-configuration-%5BCN%5D" + "help": "セイレーン作戦のプッシュ通知設定。セイレーン作戦プッシュが有効な場合、このオプションが有効なときにのみ通知が送信されます" }, "AutoSearchTimeLimit": { "name": "自動探索時間制限", @@ -3048,7 +3048,7 @@ }, "EnsureFullyCollected": { "name": "毎日200ビーコンデータの収集を保証", - "help": "収集が満了するまで、行動力保留設定を一時的に無視します。同時に「ショートカット接続」をオンにする必要があります。" + "help": "短猫相接の侵蝕等級が低い海域を削除し、資金を節約します" }, "AutoCollectShip": { "name": "新META艦船を自動取得", @@ -3085,7 +3085,7 @@ }, "OpsiExplore": { "_info": { - "name": "セイレーン作戦開拓", + "name": "セイレーン作戦開拓Plus", "help": "毎月初めにセイレーン作戦の全海域を開拓します。作戦情報記録装置(燃料5000の道具)の購入は不要です。\nこの機能は空域探索のクールダウンを確保するため、27分ごとに海域をクリアします。\nこの機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦のメインストーリーをクリアし、模擬戦闘+セイレーン試験場を完了" }, "SpecialRadar": { @@ -3111,7 +3111,7 @@ }, "OpsiShop": { "_info": { - "name": "セイレーン作戦ショップ", + "name": "セイレーン作戦ショップPlus", "help": "この機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦のメインストーリーをクリアし、模擬戦+セイレーン試験場を完了\n- セイレーン作戦開拓ミッションを有効にするか、作戦情報記録装置(燃料5000の道具)を使用します\n- セイレーン作戦メインストーリークリア翌月、港ショップが新バージョンになってから利用可能" }, "PresetFilter": { @@ -3128,13 +3128,13 @@ "help": "カスタムフィルターを使用するには「港ショップフィルター」を「カスタム」に設定し、https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn を参照してください。" }, "DisableBeforeDate": { - "name": "毎月X日以前にセイレーン作戦ショップで購入しない", + "name": "毎月X日以前にセイレーン作戦ショップPlusで購入しない", "help": "指定日の翌日から1日1回セイレーン作戦ショップで購入する\n0は制限なし、毎日購入することを意味します" } }, "OpsiVoucher": { "_info": { - "name": "特別引換店舗(月額ホワイトチケットストア)", + "name": "特別交換ショップ(毎月ホワイトチケットショップ)", "help": "月額ホワイトチケットストアを購入する\nこの機能を使用する前に、次の条件を満たす必要があります。\n- セイレーン作戦のメインストーリーをクリアし、模擬戦闘 + セイレーン試験場を完了" }, "Filter": { @@ -3144,11 +3144,11 @@ }, "OpsiDaily": { "_info": { - "name": "セイレーン作戦デイリー", + "name": "セイレーン作戦デイリーPlus", "help": "この機能を使用する前に、次の条件を満たす必要があります。\n- 大きな世界のメインストーリーをクリアし、模擬戦+セイレーン試験場を完了\n- セイレーン作戦開拓ミッションを有効にするか、作戦情報記録装置(燃料5000の道具)を使用します" }, "DoMission": { - "name": "セイレーン作戦デイリーを実行", + "name": "セイレーン作戦デイリーPlusを実行", "help": "毎日港で受け取り、毎日すべて完了します" }, "UseTuningSample": { @@ -3156,7 +3156,7 @@ "help": "6 つのパフォーマンス サンプルすべてと強力なパフォーマンス サンプルを使用する" }, "SkipSirenResearchMission": { - "name": "セイレーンの研究クエストをやっていない", + "name": "セイレーン研究任務をスキップ", "help": "有効にすると、「Siren Research・I」と「Siren Research・II」の 2 つのミッションがスキップされます。\nセイレーン調査ミッションで黄色のコインを使用して紫色のコインと交換します" }, "KeepMissionZone": { @@ -3236,8 +3236,8 @@ }, "OpsiMeowfficerFarming": { "_info": { - "name": "ショートカット接続", - "help": "指定された侵食レベルの海域を時計回りに攻撃する\nこの機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦のメインストーリーをクリアし、模擬戦+セイレーン試験場を完了\n- セイレーン作戦開拓ミッションを有効にするか、作戦情報記録装置(燃料5000の道具)を使用します" + "name": "短猫相接", + "help": "時計回りに指定侵食レベルの海域を出撃します。この機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦メインをクリアし、シミュレーション+セイレーン試験場を完了\n- 每月開拓+タスクを有効にするか戦役情報記録儀(5000燃料アイテム)を使用" }, "ActionPointPreserve": { "name": "行動力をXポイント保留", @@ -3263,7 +3263,7 @@ }, "APPreserveUntilReset": { "name": "月末までアクティブに過ごしましょう", - "help": "「X アクション ポイントを保持」設定で、オーバーワールド リセットまでの日数が減らなくなりました\n注: これらのアクションは、オンにすると、月をまたぐ日次タスクでのみ使用されます。この設定は、月をまたぐ日次タスクが有効になっていない場合は無効です。つまり、より高いメリットを得るには、これらのアクションを手動で使用する必要があります。" + "help": "セイレーン作戦リセット前に、保留行動力を自動的に0に設定し、残余行動力を消費します。侵蝕1レベリングとの併用を推奨します。" } }, "OpsiTarget": { @@ -3298,7 +3298,7 @@ }, "OperationCoinsPreserve": { "name": "戦闘補給品引換券を X 個保持する", - "help": "戦闘供給バウチャーが X より低い場合は、短い cat 接続を実行します。変更することはお勧めできません。" + "help": "短猫相接の侵蝕等級が低い海域を削除し、資金を節約します" }, "MinimumActionPointReserve": { "name": "X ポイントのアクションを保持する", @@ -3319,7 +3319,7 @@ "help": "デフォルトでは体力ナノダのみ購入されます。パープルコインも同時に購入したい場合は「ActionPoint > PurpleCoins」に設定してください" }, "RecordNonCL1AP": { - "name": "統計非侵食 1 ソース体力", + "name": "非侵蝕1由来の行動力を統計", "help": "オンにすると、すべてのソース(明石店での購入など)のスタミナ獲得データがカウントされます。オフにすると侵食1レベリングタスクのみ体力データがカウントされ、他のタスクや手動操作で得た体力は無視されます。" }, "RecordSeaMiles": { @@ -3356,7 +3356,7 @@ }, "OpsiCheckLeveling": { "_info": { - "name": "大世界艦隊体験テスト", + "name": "セイレーン作戦艦隊経験値チェック", "help": "出撃艦隊の全艦が経験値を積んでいるか確認し、積んでいればプッシュメッセージを送信する\nまず最初に設定で「エラープッシュ設定」を構成する必要があります" }, "TargetLevel": { @@ -3364,7 +3364,7 @@ "help": "すべての船が満たされたレベルに上昇するのに十分な経験を蓄積すると、メッセージ プッシュがトリガーされます。" }, "LastRun": { - "name": "エクスペリエンスが最後にチェックされた時間 (自動的に記録されます)", + "name": "前回経験値チェック時間(自動記録)", "help": "タスクが繰り返し実行されるのを防ぐために、最後のチェック時刻が記録されます。チェック間隔は 1 日です。この値は自動的に記録されるため、通常は手動で変更する必要はありません。" }, "CheckInterval": { @@ -3412,11 +3412,11 @@ }, "OpsiScheduling": { "_info": { - "name": "インテリジェントスケジューリング", + "name": "スマートスケジューリングPlus", "help": "セイレーン作戦のインテリジェントスケジューリング機能は、侵蝕1レベリングとイエローコイン補充タスク間の切り替えを調整します。\n有効にすると、さまざまなタスクが自動的に切り替わり、ゴールド コインとモビリティに基づいてプッシュ通知が送信されます。\n\n悲しいかな、オリジナルのいくつかの絶対確実な制限が解除されました。パラメータを慎重に設定してください。" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "X 個の戦闘物資引換券を入手する", + "name": "作戦補給トークンをX個保留", "help": "有効にすると、以下に設定された戦闘補給券をX個入手する構成が使用されます。無効にすると、侵食 1 レベリングと黄色のコイン保持構成が使用されます。\n閉じたときにモビリティ制御が使用されます\n開いた後、黄色のコインを使用して制御します", "True": "有効", "False": "無効" @@ -3426,33 +3426,33 @@ "help": "戦闘補給バウチャーが X より低い場合は、黄色のコイン補充タスクを実行します。これを変更することはお勧めできません。" }, "ActionPointPreserve": { - "name": "ショートキャットは X ポイントの可動性を接続して保持します", + "name": "短猫相接 行動力をXポイント保留", "help": "モビリティがX以下になると停止し、モビリティボックスが自動的に開きます。" }, "OperationCoinsReturnThreshold": { - "name": "X 個の戦闘物資引換券を入手する", + "name": "作戦補給トークンがX以下で帰投", "help": "黄色のコイン獲得ミッション中に、戦闘補給券を合計 X 枚獲得 (増加) し、レベルを上げるために侵食 1 に戻ります。" }, "EnableMeowfficerFarming": { - "name": "ショートキャット接続を有効にする", + "name": "短猫相接を有効にする", "help": "インテリジェント スケジューリングでショート キャット接続タスクが黄色のコインを補充できるようにするかどうか", "True": "有効", "False": "無効" }, "EnableObscure": { - "name": "未知の海を可能にする", + "name": "隠秘海域を有効にする", "help": "黄色のコインを補充するためにインテリジェントなスケジューリングでHidden Seaミッションを有効にするかどうか", "True": "有効", "False": "無効" }, "EnableAbyssal": { - "name": "深海を有効にする", + "name": "深淵座標を有効にする", "help": "イエローコインを補充するためのインテリジェントなスケジューリングで深海ミッションを有効にするかどうか", "True": "有効", "False": "無効" }, "EnableStronghold": { - "name": "セイレン要塞を有効にする", + "name": "セイレーン要塞を有効にする", "help": "黄色のコインを補充するためにインテリジェント スケジューリングで Siren Fortress タスクを有効にするかどうか", "True": "有効", "False": "無効" @@ -3460,6 +3460,10 @@ "TaskPriority": { "name": "作戦コイン補充優先度", "help": "作戦コイン(黄)が不足している場合、作戦コイン補充任務の実行優先順位。> で区切ります。" + }, + "APreserveUntilReset": { + "name": "月末リセット時に行動力を強制消費", + "help": "セイレーン作戦リセット前に、保留行動力を自動的に0に設定し、残余行動力を消費します。侵蝕1レベリングとの併用を推奨します。" } }, "OpsiPreventActionPointOverflow": { @@ -3472,7 +3476,7 @@ "help": "専用処理があるスマートスケジューリングを推奨します", "OpsiScheduling": "スマートスケジューリング(推奨)", "OpsiHazard1Leveling": "侵蝕1レベリング", - "OpsiMeowfficerFarming": "指揮ニャ周回" + "OpsiMeowfficerFarming": "短猫相接" }, "ActionPointUpperbound": { "name": "現在行動力上限", @@ -3691,7 +3695,7 @@ "help": "" }, "Positions": { - "name": "ポジション数", + "name": "鉱夫をX人配置", "help": "", "1": "1", "2": "2", @@ -3738,7 +3742,7 @@ "help": "" }, "Positions": { - "name": "ポジション数", + "name": "木こりをX人配置", "help": "", "1": "1", "2": "2", @@ -3787,7 +3791,7 @@ "help": "メニューは1~8の順に生産されます。生産目標はメニューX数量オプションで、前の項目が完全に満たされてから次の項目が実行されます。後続の項目は実行時にそれ以前の項目の達成に影響しません。\nメニュー1-8の涼拌双筍と筍と海老の炒め物は現在の季節に応じて対応する季節メニューに自動切替されます。" }, "PostNumber": { - "name": "ポジション数", + "name": "シェフをX人配置", "help": "", "1": "1", "2": "2" @@ -3818,7 +3822,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber1": { - "name": "メニュー1数量", + "name": "メニュー1をX人前生産", "help": "" }, "Meal2": { @@ -3839,7 +3843,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber2": { - "name": "メニュー2数量", + "name": "メニュー2をX人前生産", "help": "" }, "Meal3": { @@ -3860,7 +3864,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber3": { - "name": "メニュー3数量", + "name": "メニュー3をX人前生産", "help": "" }, "Meal4": { @@ -3881,7 +3885,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber4": { - "name": "メニュー4数量", + "name": "メニュー4をX人前生産", "help": "" }, "Meal5": { @@ -3902,7 +3906,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber5": { - "name": "メニュー5数量", + "name": "メニュー5をX人前生産", "help": "" }, "Meal6": { @@ -3923,7 +3927,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber6": { - "name": "メニュー6数量", + "name": "メニュー6をX人前生産", "help": "" }, "Meal7": { @@ -3944,7 +3948,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber7": { - "name": "メニュー7数量", + "name": "メニュー7をX人前生産", "help": "" }, "Meal8": { @@ -3965,7 +3969,7 @@ "onion_fish": "玉ねぎ蒸し魚" }, "MealNumber8": { - "name": "メニュー8数量", + "name": "メニュー8をX人前生産", "help": "" } }, @@ -3998,7 +4002,7 @@ "help": "メニューは1~8の順に生産されます。生産目標はメニューX数量オプションで、前の項目が完全に満たされてから次の項目が実行されます。後続の項目は実行時にそれ以前の項目の達成に影響しません。\nハニー製品は手動で作成してください。\nメニューの春花茶とフレッシュパイナップルジュースは現在の季節に応じて対応する季節メニューに自動切替されます。" }, "PostNumber": { - "name": "ポジション数", + "name": "バーテンダーをX人配置", "help": "", "1": "1", "2": "2" @@ -4027,7 +4031,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber1": { - "name": "メニュー1数量", + "name": "メニュー1をX人前生産", "help": "" }, "Meal2": { @@ -4046,7 +4050,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber2": { - "name": "メニュー2数量", + "name": "メニュー2をX人前生産", "help": "" }, "Meal3": { @@ -4065,7 +4069,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber3": { - "name": "メニュー3数量", + "name": "メニュー3をX人前生産", "help": "" }, "Meal4": { @@ -4084,7 +4088,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber4": { - "name": "メニュー4数量", + "name": "メニュー4をX人前生産", "help": "" }, "Meal5": { @@ -4103,7 +4107,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber5": { - "name": "メニュー5数量", + "name": "メニュー5をX人前生産", "help": "" }, "Meal6": { @@ -4122,7 +4126,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber6": { - "name": "メニュー6数量", + "name": "メニュー6をX人前生産", "help": "" }, "Meal7": { @@ -4141,7 +4145,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber7": { - "name": "メニュー7数量", + "name": "メニュー7をX人前生産", "help": "" }, "Meal8": { @@ -4160,7 +4164,7 @@ "spring_flower_tea": "春花茶" }, "MealNumber8": { - "name": "メニュー8数量", + "name": "メニュー8をX人前生産", "help": "" } }, @@ -4191,7 +4195,7 @@ "help": "メニューは1~8の順に生産されます。生産目標はメニューX数量オプションで、前の項目が完全に満たされてから次の項目が実行されます。後続の項目は実行時にそれ以前の項目の達成に影響しません。" }, "PostNumber": { - "name": "ポジション数", + "name": "グリルシェフをX人配置", "help": "", "1": "1", "2": "2" @@ -4214,7 +4218,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber1": { - "name": "メニュー1数量", + "name": "メニュー1をX人前生産", "help": "" }, "Meal2": { @@ -4231,7 +4235,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber2": { - "name": "メニュー2数量", + "name": "メニュー2をX人前生産", "help": "" }, "Meal3": { @@ -4248,7 +4252,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber3": { - "name": "メニュー3数量", + "name": "メニュー3をX人前生産", "help": "" }, "Meal4": { @@ -4265,7 +4269,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber4": { - "name": "メニュー4数量", + "name": "メニュー4をX人前生産", "help": "" }, "Meal5": { @@ -4282,7 +4286,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber5": { - "name": "メニュー5数量", + "name": "メニュー5をX人前生産", "help": "" }, "Meal6": { @@ -4299,7 +4303,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber6": { - "name": "メニュー6数量", + "name": "メニュー6をX人前生産", "help": "" }, "Meal7": { @@ -4316,7 +4320,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber7": { - "name": "メニュー7数量", + "name": "メニュー7をX人前生産", "help": "" }, "Meal8": { @@ -4333,7 +4337,7 @@ "double_energy": "エネルギーダブルセット" }, "MealNumber8": { - "name": "メニュー8数量", + "name": "メニュー8をX人前生産", "help": "" } }, @@ -4362,7 +4366,7 @@ "help": "メニューは1~8の順に生産されます。生産目標はメニューX数量オプションで、前の項目が完全に満たされてから次の項目が実行されます。後続の項目は実行時にそれ以前の項目の達成に影響しません。" }, "PostNumber": { - "name": "ポジション数", + "name": "クックをX人配置", "help": "", "1": "1", "2": "2" @@ -4387,7 +4391,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber1": { - "name": "メニュー1数量", + "name": "メニュー1をX人前生産", "help": "" }, "Meal2": { @@ -4406,7 +4410,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber2": { - "name": "メニュー2数量", + "name": "メニュー2をX人前生産", "help": "" }, "Meal3": { @@ -4425,7 +4429,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber3": { - "name": "メニュー3数量", + "name": "メニュー3をX人前生産", "help": "" }, "Meal4": { @@ -4444,7 +4448,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber4": { - "name": "メニュー4数量", + "name": "メニュー4をX人前生産", "help": "" }, "Meal5": { @@ -4463,7 +4467,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber5": { - "name": "メニュー5数量", + "name": "メニュー5をX人前生産", "help": "" }, "Meal6": { @@ -4482,7 +4486,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber6": { - "name": "メニュー6数量", + "name": "メニュー6をX人前生産", "help": "" }, "Meal7": { @@ -4501,7 +4505,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber7": { - "name": "メニュー7数量", + "name": "メニュー7をX人前生産", "help": "" }, "Meal8": { @@ -4520,7 +4524,7 @@ "seafood_rice": "シーフードライス" }, "MealNumber8": { - "name": "メニュー8数量", + "name": "メニュー8をX人前生産", "help": "" } }, @@ -4551,7 +4555,7 @@ "help": "メニューは1~8の順に生産されます。生産目標はメニューX数量オプションで、前の項目が完全に満たされてから次の項目が実行されます。後続の項目は実行時にそれ以前の項目の達成に影響しません。" }, "PostNumber": { - "name": "ポジション数", + "name": "バリスタをX人配置", "help": "", "1": "1", "2": "2" @@ -4579,7 +4583,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber1": { - "name": "メニュー1数量", + "name": "メニュー1をX人前生産", "help": "" }, "Meal2": { @@ -4597,7 +4601,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber2": { - "name": "メニュー2数量", + "name": "メニュー2をX人前生産", "help": "" }, "Meal3": { @@ -4615,7 +4619,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber3": { - "name": "メニュー3数量", + "name": "メニュー3をX人前生産", "help": "" }, "Meal4": { @@ -4633,7 +4637,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber4": { - "name": "メニュー4数量", + "name": "メニュー4をX人前生産", "help": "" }, "Meal5": { @@ -4651,7 +4655,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber5": { - "name": "メニュー5数量", + "name": "メニュー5をX人前生産", "help": "" }, "Meal6": { @@ -4669,7 +4673,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber6": { - "name": "メニュー6数量", + "name": "メニュー6をX人前生産", "help": "" }, "Meal7": { @@ -4687,7 +4691,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber7": { - "name": "メニュー7数量", + "name": "メニュー7をX人前生産", "help": "" }, "Meal8": { @@ -4705,7 +4709,7 @@ "fruity_fruitier": "フルーティーフルーティエ" }, "MealNumber8": { - "name": "メニュー8数量", + "name": "メニュー8をX人前生産", "help": "" } }, @@ -4897,7 +4901,7 @@ "False": "無効" }, "SeasonalThreshold": { - "name": "季節置き換え閾値", + "name": "季節限定アイテムがX以下で置換", "help": "在庫がこの数値を下回ると置き換えを実行" } }, @@ -5639,7 +5643,7 @@ "help": "現在の骨組み: ドック、先頭艦、クイック変更、装備中フィルター、仮の装備ロジック、次の艦。" }, "ShipLimit": { - "name": "処理する艦船数の上限", + "name": "最初のX隻を処理", "help": "0 は次の艦船が見つからなくなるまで処理します。デバッグ時は 1 など小さい値を指定してください。" }, "EnableSlot1": { @@ -5679,7 +5683,7 @@ "help": "どちらのスクリーンショット スキームと制御スキームが速いかをテストします。速度はエミュレータとコンピュータの構成によって異なります。\nテストが完了したら、「AzurPilot」-「シミュレーター設定」で最速のスクリーンショットと制御スキームを選択します。" }, "DeviceType": { - "name": "デバイスの種類", + "name": "デバイスタイプを選択", "help": "", "emulator": "エミュレータ", "plone_cloud_with_adb": "クラウド携帯電話は公衆網ADBを搭載", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 61c70d01f..225710c92 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -1,15 +1,15 @@ { "Menu": { "Alas": { - "name": "智慧港区+", + "name": "智慧港区Plus", "help": "" }, "Farm": { - "name": "出击+", + "name": "出击Plus", "help": "" }, "Event": { - "name": "活动+", + "name": "活动Plus", "help": "" }, "EventDaily": { @@ -25,7 +25,7 @@ "help": "" }, "Opsi": { - "name": "大世界+", + "name": "大世界Plus", "help": "" }, "Island": { @@ -33,7 +33,7 @@ "help": "" }, "Tool": { - "name": "工具+", + "name": "工具Plus", "help": "" } }, @@ -51,23 +51,23 @@ "help": "" }, "Main": { - "name": "主线图-1+", + "name": "主线图-1Plus", "help": "" }, "Main2": { - "name": "主线图-2+", + "name": "主线图-2Plus", "help": "" }, "Main3": { - "name": "主线图-3+", + "name": "主线图-3Plus", "help": "" }, "GemsFarming": { - "name": "紧急委托+", + "name": "紧急委托Plus", "help": "" }, "ThreeOilLowCost": { - "name": "三油低耗+", + "name": "三油低耗Plus", "help": "使用三油低耗阵容进行战斗,在旗舰达到32级时自动更换旗舰并退役。" }, "Ambush11": { @@ -79,23 +79,23 @@ "help": "" }, "Event": { - "name": "活动图-1+", + "name": "活动图-1Plus", "help": "" }, "Event2": { - "name": "活动图-2+", + "name": "活动图-2Plus", "help": "" }, "Event3": { - "name": "活动图-3+", + "name": "活动图-3Plus", "help": "" }, "Raid": { - "name": "共斗活动+", + "name": "共斗活动Plus", "help": "" }, "RaidScuttle": { - "name": "共斗沉船+", + "name": "共斗沉船Plus", "help": "" }, "Hospital": { @@ -235,11 +235,11 @@ "help": "" }, "OpsiExplore": { - "name": "每月开荒+", + "name": "每月开荒Plus", "help": "" }, "OpsiShop": { - "name": "大世界商店+", + "name": "大世界商店Plus", "help": "" }, "OpsiVoucher": { @@ -247,7 +247,7 @@ "help": "" }, "OpsiDaily": { - "name": "大世界每日+", + "name": "大世界每日Plus", "help": "" }, "OpsiObscure": { @@ -260,7 +260,7 @@ }, "OpsiArchive": { "name": "档案坐标", - "help": "从白票商店购买档案坐标并运行,档案海域不消耗行动力\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "从白票商店购买档案坐标并运行,档案海域不消耗行动力\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "OpsiStronghold": { "name": "塞壬要塞", @@ -279,8 +279,8 @@ "help": "此功能开放了大量配置,默认配置已经可以满足大部分需求,配置前请确保你已了解侵蚀1的基础原理,后果自负" }, "OpsiScheduling": { - "name": "智能调度+", - "help": "大世界智能调度+功能,开启后无需重复开启侵蚀1等任务,智能调度+将全部接管" + "name": "智能调度Plus", + "help": "大世界智能调度Plus功能,开启后无需重复开启侵蚀1等任务,智能调度Plus将全部接管" }, "OpsiPreventActionPointOverflow": { "name": "防止行动力溢出", @@ -288,11 +288,11 @@ }, "OpsiCrossMonth": { "name": "跨月每日", - "help": " AzurPilot将在大世界跨月重置之前10分钟进入大世界,等待大世界重置但不退出大世界,然后完成新一天的大世界每日+、隐秘海域、深渊坐标和耄耋相接,以获得额外的金菜。运行大世界每日+时,按\"大世界每日+\"任务设置运行,其余同理。\n 重要:AzurPilot等待跨月期间,请不要操作游戏。" + "help": " AzurPilot将在大世界跨月重置之前10分钟进入大世界,等待大世界重置但不退出大世界,然后完成新一天的大世界每日Plus、隐秘海域、深渊坐标和耄耋相接,以获得额外的金菜。运行大世界每日Plus时,按\"大世界每日Plus\"任务设置运行,其余同理。\n 重要:AzurPilot等待跨月期间,请不要操作游戏。" }, "OpsiSimulator": { "name": "大世界模拟器 Alpha", - "help": "使用蒙特卡洛算法估算在当前状态下持续进行侵蚀循环一段时间后的结果,该功能不会影响AzurPilot正常运行\n模型所用参数均取自AzurPilot配置和统计,请确保正确配置了智能调度+等大世界设置\n目前为测试版本,结果不具备太多参考价值\n关于奸商售卖行动力的模型无足够充分的证据支撑。具体来讲,这里认为奸商卖6种行动力箱子满足超几何分布X~H(6,6,24)\n关于坠机:这里坠机是指整个模拟流程中行动力低于智能调度+中设置的行动力保留阈值至少一次。若低于该阈值,模拟器将停止侵蚀循环等待行动力自然回复" + "help": "使用蒙特卡洛算法估算在当前状态下持续进行侵蚀循环一段时间后的结果,该功能不会影响AzurPilot正常运行\n模型所用参数均取自AzurPilot配置和统计,请确保正确配置了智能调度Plus等大世界设置\n目前为测试版本,结果不具备太多参考价值\n关于奸商售卖行动力的模型无足够充分的证据支撑。具体来讲,这里认为奸商卖6种行动力箱子满足超几何分布X~H(6,6,24)\n关于坠机:这里坠机是指整个模拟流程中行动力低于智能调度Plus中设置的行动力保留阈值至少一次。若低于该阈值,模拟器将停止侵蚀循环等待行动力自然回复" }, "IslandPlan": { "name": "全局配置", @@ -575,7 +575,7 @@ "jp-17": "[JP] スイートバン" }, "ScreenshotMethod": { - "name": "模拟器截图方案", + "name": "选择截图方案", "help": "使用自动选择时,将执行一次性能测试并自动更改为最快的截图方案\n一般情况下的速度: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB\n运行 工具 - 性能测试 以寻找最快的方案", "auto": "自动选择最快的", "ADB": "ADB", @@ -589,7 +589,7 @@ "ldopengl": "ldopengl" }, "ControlMethod": { - "name": "模拟器控制方案", + "name": "选择控制方案", "help": "速度: minitouch > Hermit >>> uiautomator2 ~= ADB\n建议选minitouch,不建议选Hermit除非在vmos下", "ADB": "ADB", "uiautomator2": "uiautomator2", @@ -618,7 +618,7 @@ "help": "下列数值是根据Serial自动填充的,如果不懂请不要随意修改,修改端口即可自动启动对应实例" }, "Emulator": { - "name": "模拟器类型", + "name": "选择模拟器类型", "help": "", "auto": "自动检测", "NoxPlayer": "夜神模拟器", @@ -696,7 +696,7 @@ "help": "运行敏感任务出错时停止AzurPilot,而不是重启游戏\n敏感任务包含隐秘海域、深渊坐标、跨月每日" }, "SaveErrorCount": { - "name": "Error Log 保存上限", + "name": "最多保存 X 个错误日志", "help": "超出上限的错误日志将被删除,若为0或负值则没有限制" }, "OnePushConfig": { @@ -712,7 +712,7 @@ "help": "在游戏连续卡死(触发GameStuckError)达到指定次数后,尝试重启模拟器。可能有助于解决模拟器假死或显存溢出等问题。" }, "GameStuckThreshold": { - "name": "游戏卡死重启阈值", + "name": "游戏卡死超过 X 秒后重启", "help": "连续卡死多少次后触发重启。" }, "AdbOfflineRestart": { @@ -720,7 +720,7 @@ "help": "在连续检测不到设备(ADB离线或连接失败)达到指定次数后,尝试重启模拟器。" }, "AdbOfflineThreshold": { - "name": "检测不到设备重启阈值", + "name": "检测不到设备超过 X 秒后重启", "help": "连续检测不到设备多少次后触发重启。" }, "LlmAnalysis": { @@ -934,7 +934,7 @@ "help": "" }, "RetireMode": { - "name": "退役模式", + "name": "选择退役模式", "help": "若选择强化,强化材料不足时回退到一键退役", "one_click_retire": "一键退役", "enhance": "强化", @@ -1024,7 +1024,7 @@ "help": "格式: \"cv > bb > ...\",\n留空则使用默认强化方式" }, "CheckPerCategory": { - "name": "每分类强化数量", + "name": "每个分类检查 X 艘", "help": "每个舰船分类最多强化多少舰船,\n在战斗中的舰船会被跳过且不计入,\n优先判断是否有强化材料" } }, @@ -1050,7 +1050,7 @@ "help": "" }, "RetireAmount": { - "name": "退役数量", + "name": "每次退役 X 艘", "help": "", "retire_all": "退役全部", "retire_10": "退役10个" @@ -1221,7 +1221,7 @@ "war_archives_20240725_cn": "档案 幻梦间奏曲" }, "Mode": { - "name": "关卡模式", + "name": "选择关卡模式", "help": "仅适用于主线图", "normal": "普通", "hard": "困难" @@ -1304,7 +1304,7 @@ "help": "" }, "DailyRunCount": { - "name": "每日出击次数", + "name": "每日出击 X 次", "help": "每天最多运行作战档案多少次。\n0 表示不限制。\n达到次数后会延迟到下一次服务器刷新,不会关闭任务。" }, "DailyRunCountRemain": { @@ -1326,7 +1326,7 @@ "help": "" }, "Fleet1": { - "name": "一队编号", + "name": "一队使用第 X 支舰队", "help": "", "1": "1 ", "2": "2 ", @@ -1343,7 +1343,7 @@ "diamond": "轮形阵" }, "Fleet1Mode": { - "name": "一队自律模式", + "name": "一队自律战斗模式", "help": "", "combat_auto": "自律战斗", "combat_manual": "手操", @@ -1360,7 +1360,7 @@ "5": "5 " }, "Fleet2": { - "name": "二队编号", + "name": "二队使用第 X 支舰队", "help": "", "0": "不使用", "1": "1 ", @@ -1378,7 +1378,7 @@ "diamond": "轮形阵" }, "Fleet2Mode": { - "name": "二队自律模式", + "name": "二队自律战斗模式", "help": "", "combat_auto": "自律战斗", "combat_manual": "手操", @@ -1413,14 +1413,14 @@ "help": "" }, "Fleet": { - "name": "潜艇舰队编号", + "name": "使用第 X 潜艇编队", "help": "", "0": "不使用", "1": "1 ", "2": "2 " }, "Mode": { - "name": "潜艇出击方案", + "name": "选择潜艇出击方案", "help": "仅在自律寻敌关闭的情况下生效,提醒: '狩猎及BOSS战'为'仅狩猎'与'仅BOSS战'的混合,它会在道中进行狩猎打击,并在BOSS战尝试召唤潜艇。", "do_not_use": "不使用", "hunt_only": "仅狩猎", @@ -1429,7 +1429,7 @@ "every_combat": "每战出击" }, "AutoSearchMode": { - "name": "潜艇自律方案", + "name": "选择潜艇自律方案", "help": "仅在自律寻敌下生效", "sub_standby": "待机", "sub_auto_call": "自动召唤潜艇" @@ -1722,7 +1722,7 @@ "help": "" }, "PtLimit": { - "name": "活动 PT 限制", + "name": "活动PT达到 X 后停止", "help": "当累计 PT 达到给定数值时,禁用所有活动任务,0 表示不限制,使用自律寻敌时不能即时触发\n开启后,你只需要打开所有活动任务,而不需要计算一共打多少次" }, "TimeLimit": { @@ -1740,7 +1740,7 @@ "help": "启用后,将于每次出击后检查物资量" }, "CoinLimit": { - "name": "保持物资大于X", + "name": "保持物资大于 X", "help": "" }, "TaskCall": { @@ -1982,19 +1982,19 @@ "help": "" }, "T4Allow": { - "name": "允许 T4 技能书溢出 X 点经验", + "name": "允许T4技能书溢出 X 点经验", "help": "" }, "T3Allow": { - "name": "允许 T3 技能书溢出 X 点经验", + "name": "允许T3技能书溢出 X 点经验", "help": "" }, "T2Allow": { - "name": "允许 T2 技能书溢出 X 点经验", + "name": "允许T2技能书溢出 X 点经验", "help": "" }, "T1Allow": { - "name": "允许 T1 技能书溢出 X 点经验", + "name": "允许T1技能书溢出 X 点经验", "help": "" } }, @@ -2158,7 +2158,7 @@ "help": "" }, "Mode": { - "name": "训练模式", + "name": "选择训练模式", "help": "无缝练猫将每隔 2.5 ~ 3.5 小时收取指挥喵\n每天收一只意思是每天收一只,周日收全部、强化并重新填充队列,以完成每周任务", "seamlessly": "无缝练猫", "once_a_day": "每天收一只" @@ -2533,7 +2533,7 @@ "6": "六期科研" }, "ShipIndex": { - "name": "舰船序号", + "name": "选择第 X 艘科研船", "help": "从左往右数,从 1 开始", "0": "不购买", "1": "1", @@ -2568,7 +2568,7 @@ "6": "六期科研" }, "ShipIndex": { - "name": "舰船序号", + "name": "选择第 X 艘科研船", "help": "从左往右数,从1开始", "0": "不购买", "1": "1", @@ -2602,7 +2602,7 @@ "wishing_well": "祈愿池" }, "Amount": { - "name": "抽卡数量", + "name": "每天建造 X 次", "help": "", "1": "1", "2": "2", @@ -2918,7 +2918,7 @@ "help": "推荐 1.0 ~ 3.0,因为人物立绘会遮挡血条,所以需要一定的时间确认血量是否真的低于阈值" }, "OpponentRefreshValue": { - "name": "今日演习对手已刷新 X 次", + "name": "今日已刷新对手 X 次", "help": "自动更新的数值,过0点重置\n如果你手动刷新的对手,应该修改这个数值" }, "OpponentRefreshRecord": { @@ -2964,7 +2964,7 @@ "help": "每次进入仓库的时候,使用所有的使用坐标记录仪" }, "BuyActionPointLimit": { - "name": "买行动力X次", + "name": "购买行动力 X 次", "help": "买行动力的最大次数", "0": "不买", "1": "1次 (1000油, 100行动力)", @@ -2991,7 +2991,7 @@ }, "RepairPackThresholdHazard1": { "name": "任意舰船血量低于 X 后,使用维修箱修理(侵蚀1)", - "help": "仅在开启“使用维修箱修船”时生效\n仅在“侵蚀1练级+”任务中尝试维修\n0.0 ~ 1.0" + "help": "仅在开启“使用维修箱修船”时生效\n仅在“侵蚀1练级Plus”任务中尝试维修\n0.0 ~ 1.0" }, "DoRandomMapEvent": { "name": "完成地图随机事件", @@ -3085,7 +3085,7 @@ }, "OpsiExplore": { "_info": { - "name": "每月开荒+", + "name": "每月开荒Plus", "help": "每月月初开荒大世界的所有海域,不需要买战役信息记录仪(5000油道具)\n这个功能将每隔27分钟清理一个海域,以保证空域搜索冷却\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场" }, "SpecialRadar": { @@ -3105,14 +3105,14 @@ "help": "开启后将不会推迟侵蚀1练级任务,慎用,没体力开荒就老实了。" }, "ExploreProgress": { - "name": "每月开荒+进度", - "help": "显示当前每月开荒+的完成百分比" + "name": "每月开荒Plus进度", + "help": "显示当前每月开荒Plus的完成百分比" } }, "OpsiShop": { "_info": { - "name": "大世界商店+", - "help": "使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)\n- 通关大世界主线后的下一个月,即港口商店变为新版商店后才可使用" + "name": "大世界商店Plus", + "help": "使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)\n- 通关大世界主线后的下一个月,即港口商店变为新版商店后才可使用" }, "PresetFilter": { "name": "港口商店过滤器", @@ -3128,8 +3128,8 @@ "help": "使用自定义过滤器需将 \"港口商店过滤器\" 设置为 \"自定义\",并使用 https://filter.nanoda.work/ 生成过滤器,自己瞎几把设置过滤器出问题了别叫" }, "DisableBeforeDate": { - "name": "每月 X 号前,不购买大世界商店+", - "help": "从指定日期的后一天开始,每天买一次大世界商店+\n0 表示不限制,每天都购买" + "name": "每月 X 号前,不购买大世界商店Plus", + "help": "从指定日期的后一天开始,每天买一次大世界商店Plus\n0 表示不限制,每天都购买" } }, "OpsiVoucher": { @@ -3144,11 +3144,11 @@ }, "OpsiDaily": { "_info": { - "name": "大世界每日+", - "help": "使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "name": "大世界每日Plus", + "help": "使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "DoMission": { - "name": "做大世界每日+", + "name": "做大世界每日Plus", "help": "在港口接每日,并完成所有每日" }, "UseTuningSample": { @@ -3175,7 +3175,7 @@ "OpsiObscure": { "_info": { "name": "隐秘海域", - "help": "每隔27分钟清理一个隐秘海域,以保证空域搜索冷却\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "每隔27分钟清理一个隐秘海域,以保证空域搜索冷却\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "SkipHazard2Obscure": { "name": "不清理2级坐标", @@ -3189,7 +3189,7 @@ "OpsiAbyssal": { "_info": { "name": "深渊坐标", - "help": "清理仓库中的深渊坐标\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "清理仓库中的深渊坐标\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "ForceRun": { "name": "无视运行间隔强制运行", @@ -3199,7 +3199,7 @@ "OpsiStronghold": { "_info": { "name": "塞壬要塞", - "help": "清理每周的塞壬要塞\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "清理每周的塞壬要塞\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "SubmarineEveryCombat": { "name": "潜艇每战出击", @@ -3217,7 +3217,7 @@ "OpsiMonthBoss": { "_info": { "name": "月度Boss", - "help": "清理(简单/困难)月度Boss,简单难度失败会继续出击直到击破,困难难度失败则直接终止任务\n使用此功能需要满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "清理(简单/困难)月度Boss,简单难度失败会继续出击直到击破,困难难度失败则直接终止任务\n使用此功能需要满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "Mode": { "name": "清理月度Boss的难度", @@ -3237,7 +3237,7 @@ "OpsiMeowfficerFarming": { "_info": { "name": "耄耋相接", - "help": "按顺时针出击指定侵蚀等级的海域\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒+任务或使用战役信息记录仪(5000油道具)" + "help": "按顺时针出击指定侵蚀等级的海域\n使用此功能前必须满足以下条件:\n- 通关大世界主线并完成模拟战+塞壬试验场\n- 启用每月开荒Plus任务或使用战役信息记录仪(5000油道具)" }, "ActionPointPreserve": { "name": "保留 X 点行动力", @@ -3412,11 +3412,11 @@ }, "OpsiScheduling": { "_info": { - "name": "智能调度+", - "help": "智能调度+相关配置" + "name": "智能调度Plus", + "help": "智能调度Plus相关配置" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "获取 X 个作战补给凭证", + "name": "保留 X 个作战补给凭证", "help": "启用后将使用下方设置的 获取 X 个作战补给凭证 的配置,禁用则使用侵蚀1练级 黄币保留配置", "True": "开启", "False": "关闭" @@ -3430,30 +3430,30 @@ "help": "行动力低于 X 后停止,自动打开行动力箱子,X 包含箱子中的行动力,建议保留1000行动力给侵蚀1练级" }, "OperationCoinsReturnThreshold": { - "name": "获取 X 个作战补给凭证", + "name": "作战补给凭证低于 X 时返航", "help": "在黄币获取任务时 共获取(增加) X 个作战补给凭证后返回侵蚀1练级" }, "EnableMeowfficerFarming": { "name": "启用耄耋相接", - "help": "智能调度+中是否启用耄耋相接任务来补充黄币", + "help": "智能调度Plus中是否启用耄耋相接任务来补充黄币", "True": "已启用", "False": "未启用" }, "EnableObscure": { "name": "启用隐秘海域", - "help": "智能调度+中是否启用隐秘海域任务来补充黄币", + "help": "智能调度Plus中是否启用隐秘海域任务来补充黄币", "True": "已启用", "False": "未启用" }, "EnableAbyssal": { "name": "启用深渊坐标", - "help": "智能调度+中是否启用深渊坐标任务来补充黄币", + "help": "智能调度Plus中是否启用深渊坐标任务来补充黄币", "True": "已启用", "False": "未启用" }, "EnableStronghold": { "name": "启用塞壬要塞", - "help": "智能调度+中是否启用塞壬要塞任务来补充黄币", + "help": "智能调度Plus中是否启用塞壬要塞任务来补充黄币", "True": "已启用", "False": "未启用" }, @@ -3465,11 +3465,11 @@ "OpsiPreventActionPointOverflow": { "_info": { "name": "防止行动力溢出", - "help": "当前行动力达到上限时执行大世界任务消耗行动力\n选择智能调度+时,如果行动力和黄币都不足导致没有可运行任务,将运行耄耋相接清理当前行动力" + "help": "当前行动力达到上限时执行大世界任务消耗行动力\n选择智能调度Plus时,如果行动力和黄币都不足导致没有可运行任务,将运行耄耋相接清理当前行动力" }, "Task": { "name": "执行任务", - "help": "推荐选择智能调度+,防止行动力溢出任务对此有专门处理", + "help": "推荐选择智能调度Plus,防止行动力溢出任务对此有专门处理", "OpsiScheduling": "智能调度+(推荐)", "OpsiHazard1Leveling": "侵蚀1练级", "OpsiMeowfficerFarming": "耄耋相接" @@ -3691,7 +3691,7 @@ "help": "" }, "Positions": { - "name": "岗位数量", + "name": "安排 X 个矿工岗位", "help": "", "1": "1", "2": "2", @@ -3738,7 +3738,7 @@ "help": "" }, "Positions": { - "name": "岗位数量", + "name": "安排 X 个伐木工岗位", "help": "", "1": "1", "2": "2", @@ -3787,7 +3787,7 @@ "help": "餐品会按从1~8的顺序依次生产,生产目标为餐品X数量选项,完全满足前一项后才会执行下一项,后一项在运行时不会影响之前几项任务的达成\n餐品1-8中的凉拌双笋和芦笋炒虾仁会根据当前季节自动切换为对应季节餐品" }, "PostNumber": { - "name": "岗位数量", + "name": "安排 X 个厨师岗位", "help": "", "1": "1", "2": "2" @@ -3818,7 +3818,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber1": { - "name": "餐品1数量", + "name": "生产餐品1至 X 份", "help": "" }, "Meal2": { @@ -3839,7 +3839,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber2": { - "name": "餐品2数量", + "name": "生产餐品2至 X 份", "help": "" }, "Meal3": { @@ -3860,7 +3860,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber3": { - "name": "餐品3数量", + "name": "生产餐品3至 X 份", "help": "" }, "Meal4": { @@ -3881,7 +3881,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber4": { - "name": "餐品4数量", + "name": "生产餐品4至 X 份", "help": "" }, "Meal5": { @@ -3902,7 +3902,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber5": { - "name": "餐品5数量", + "name": "生产餐品5至 X 份", "help": "" }, "Meal6": { @@ -3923,7 +3923,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber6": { - "name": "餐品6数量", + "name": "生产餐品6至 X 份", "help": "" }, "Meal7": { @@ -3944,7 +3944,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber7": { - "name": "餐品7数量", + "name": "生产餐品7至 X 份", "help": "" }, "Meal8": { @@ -3965,7 +3965,7 @@ "onion_fish": "洋葱蒸鱼" }, "MealNumber8": { - "name": "餐品8数量", + "name": "生产餐品8至 X 份", "help": "" } }, @@ -3998,7 +3998,7 @@ "help": "餐品会按从1~8的顺序依次生产,生产目标为餐品X数量选项,完全满足前一项后才会执行下一项,后一项在运行时不会影响之前几项任务的达成\n请手动制作蜂蜜制品\n餐品中的迎春花茶和鲜榨菠萝汁会根据当前季节自动切换为对应季节餐品" }, "PostNumber": { - "name": "岗位数量", + "name": "安排 X 个调饮师岗位", "help": "", "1": "1", "2": "2" @@ -4027,7 +4027,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber1": { - "name": "餐品1数量", + "name": "生产餐品1至 X 份", "help": "" }, "Meal2": { @@ -4046,7 +4046,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber2": { - "name": "餐品2数量", + "name": "生产餐品2至 X 份", "help": "" }, "Meal3": { @@ -4065,7 +4065,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber3": { - "name": "餐品3数量", + "name": "生产餐品3至 X 份", "help": "" }, "Meal4": { @@ -4084,7 +4084,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber4": { - "name": "餐品4数量", + "name": "生产餐品4至 X 份", "help": "" }, "Meal5": { @@ -4103,7 +4103,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber5": { - "name": "餐品5数量", + "name": "生产餐品5至 X 份", "help": "" }, "Meal6": { @@ -4122,7 +4122,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber6": { - "name": "餐品6数量", + "name": "生产餐品6至 X 份", "help": "" }, "Meal7": { @@ -4141,7 +4141,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber7": { - "name": "餐品7数量", + "name": "生产餐品7至 X 份", "help": "" }, "Meal8": { @@ -4160,7 +4160,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber8": { - "name": "餐品8数量", + "name": "生产餐品8至 X 份", "help": "" } }, @@ -4191,7 +4191,7 @@ "help": "餐品会按从1~8的顺序依次生产,生产目标为餐品X数量选项,完全满足前一项后才会执行下一项,后一项在运行时不会影响之前几项任务的达成" }, "PostNumber": { - "name": "岗位数量", + "name": "安排 X 个烧烤师岗位", "help": "", "1": "1", "2": "2" @@ -4214,7 +4214,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber1": { - "name": "餐品1数量", + "name": "生产餐品1至 X 份", "help": "" }, "Meal2": { @@ -4231,7 +4231,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber2": { - "name": "餐品2数量", + "name": "生产餐品2至 X 份", "help": "" }, "Meal3": { @@ -4248,7 +4248,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber3": { - "name": "餐品3数量", + "name": "生产餐品3至 X 份", "help": "" }, "Meal4": { @@ -4265,7 +4265,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber4": { - "name": "餐品4数量", + "name": "生产餐品4至 X 份", "help": "" }, "Meal5": { @@ -4282,7 +4282,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber5": { - "name": "餐品5数量", + "name": "生产餐品5至 X 份", "help": "" }, "Meal6": { @@ -4299,7 +4299,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber6": { - "name": "餐品6数量", + "name": "生产餐品6至 X 份", "help": "" }, "Meal7": { @@ -4316,7 +4316,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber7": { - "name": "餐品7数量", + "name": "生产餐品7至 X 份", "help": "" }, "Meal8": { @@ -4333,7 +4333,7 @@ "double_energy": "能量双拼套餐" }, "MealNumber8": { - "name": "餐品8数量", + "name": "生产餐品8至 X 份", "help": "" } }, @@ -4362,7 +4362,7 @@ "help": "餐品会按从1~8的顺序依次生产,生产目标为餐品X数量选项,完全满足前一项后才会执行下一项,后一项在运行时不会影响之前几项任务的达成" }, "PostNumber": { - "name": "岗位数量", + "name": "安排 X 个简餐师岗位", "help": "", "1": "1", "2": "2" @@ -4387,7 +4387,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber1": { - "name": "餐品1数量", + "name": "生产餐品1至 X 份", "help": "" }, "Meal2": { @@ -4406,7 +4406,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber2": { - "name": "餐品2数量", + "name": "生产餐品2至 X 份", "help": "" }, "Meal3": { @@ -4425,7 +4425,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber3": { - "name": "餐品3数量", + "name": "生产餐品3至 X 份", "help": "" }, "Meal4": { @@ -4444,7 +4444,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber4": { - "name": "餐品4数量", + "name": "生产餐品4至 X 份", "help": "" }, "Meal5": { @@ -4463,7 +4463,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber5": { - "name": "餐品5数量", + "name": "生产餐品5至 X 份", "help": "" }, "Meal6": { @@ -4482,7 +4482,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber6": { - "name": "餐品6数量", + "name": "生产餐品6至 X 份", "help": "" }, "Meal7": { @@ -4501,7 +4501,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber7": { - "name": "餐品7数量", + "name": "生产餐品7至 X 份", "help": "" }, "Meal8": { @@ -4520,7 +4520,7 @@ "seafood_rice": "海鲜饭" }, "MealNumber8": { - "name": "餐品8数量", + "name": "生产餐品8至 X 份", "help": "" } }, @@ -4551,7 +4551,7 @@ "help": "餐品会按从1~8的顺序依次生产,生产目标为餐品X数量选项,完全满足前一项后才会执行下一项,后一项在运行时不会影响之前几项任务的达成" }, "PostNumber": { - "name": "岗位数量", + "name": "安排 X 个咖啡师岗位", "help": "", "1": "1", "2": "2" @@ -4579,7 +4579,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber1": { - "name": "餐品1数量", + "name": "生产餐品1至 X 份", "help": "" }, "Meal2": { @@ -4597,7 +4597,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber2": { - "name": "餐品2数量", + "name": "生产餐品2至 X 份", "help": "" }, "Meal3": { @@ -4615,7 +4615,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber3": { - "name": "餐品3数量", + "name": "生产餐品3至 X 份", "help": "" }, "Meal4": { @@ -4633,7 +4633,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber4": { - "name": "餐品4数量", + "name": "生产餐品4至 X 份", "help": "" }, "Meal5": { @@ -4651,7 +4651,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber5": { - "name": "餐品5数量", + "name": "生产餐品5至 X 份", "help": "" }, "Meal6": { @@ -4669,7 +4669,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber6": { - "name": "餐品6数量", + "name": "生产餐品6至 X 份", "help": "" }, "Meal7": { @@ -4687,7 +4687,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber7": { - "name": "餐品7数量", + "name": "生产餐品7至 X 份", "help": "" }, "Meal8": { @@ -4705,7 +4705,7 @@ "fruity_fruitier": "果香双杯乐" }, "MealNumber8": { - "name": "餐品8数量", + "name": "生产餐品8至 X 份", "help": "" } }, @@ -4897,7 +4897,7 @@ "False": "禁用" }, "SeasonalThreshold": { - "name": "季节限定替换阈值", + "name": "季节限定商品数量低于 X 时替换", "help": "季节限定菜品库存低于此数量时触发替换" } }, @@ -5639,7 +5639,7 @@ "help": "当前版本搭建点击流程:船坞、第一艘、快速更换、装备中、占位换装逻辑、下一艘。" }, "ShipLimit": { - "name": "处理舰船上限", + "name": "处理前 X 艘舰船", "help": "0 表示一直处理到无法切换下一艘;调试时可设置为 1 或其他较小数量。" }, "EnableSlot1": { @@ -5679,7 +5679,7 @@ "help": "测试哪个截图方案和控制方案速度更快,速度因模拟器和电脑配置而异\n测试完成后,在 \"AzurPilot\" - \"模拟器设置\" 内选择最快的截图和控制方案" }, "DeviceType": { - "name": "设备类型", + "name": "选择设备类型", "help": "", "emulator": "模拟器", "plone_cloud_with_adb": "云手机 有公网ADB", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index f2e0ead6f..4fbb395f7 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -1,15 +1,15 @@ { "Menu": { "Alas": { - "name": "智慧港區+", + "name": "智慧港區Plus", "help": "" }, "Farm": { - "name": "出擊+", + "name": "出擊Plus", "help": "" }, "Event": { - "name": "活動+", + "name": "活動Plus", "help": "" }, "EventDaily": { @@ -25,7 +25,7 @@ "help": "" }, "Opsi": { - "name": "大世界+", + "name": "大世界Plus", "help": "" }, "Island": { @@ -33,7 +33,7 @@ "help": "" }, "Tool": { - "name": "工具+", + "name": "工具Plus", "help": "" } }, @@ -51,23 +51,23 @@ "help": "" }, "Main": { - "name": "主線圖-1+", + "name": "主線圖-1Plus", "help": "" }, "Main2": { - "name": "主線圖-2+", + "name": "主線圖-2Plus", "help": "" }, "Main3": { - "name": "主線圖+", + "name": "主線圖-3Plus", "help": "" }, "GemsFarming": { - "name": "緊急委託+", + "name": "緊急委託Plus", "help": "" }, "ThreeOilLowCost": { - "name": "三油低耗+", + "name": "三油低耗Plus", "help": "使用三油低耗陣容進行戰鬥,在旗艦達到32級時自動更換旗艦並退役。" }, "Ambush11": { @@ -79,23 +79,23 @@ "help": "" }, "Event": { - "name": "活動圖-1+", + "name": "活動圖-1Plus", "help": "" }, "Event2": { - "name": "活動圖-2+", + "name": "活動圖-2Plus", "help": "" }, "Event3": { - "name": "活動圖-3+", + "name": "活動圖-3Plus", "help": "" }, "Raid": { - "name": "共鬥活動+", + "name": "共鬥活動Plus", "help": "" }, "RaidScuttle": { - "name": "共鬥沉船+", + "name": "共鬥沉船Plus", "help": "" }, "Hospital": { @@ -107,8 +107,8 @@ "help": "" }, "CoalitionScuttle": { - "name": "Task.CoalitionScuttle.name", - "help": "Task.CoalitionScuttle.help" + "name": "聯盟沉船Plus", + "help": "聯盟活動中艦船被擊沉後刷取好感度,建議多隊出擊,第一隊就沉,建議看相關教程" }, "MaritimeEscort": { "name": "商船護航", @@ -147,7 +147,7 @@ "help": "" }, "CoalitionSp": { - "name": "怪谈纪实:逃离白夜山庄SP", + "name": "怪談紀實:逃離白夜山莊SP", "help": "" }, "Commission": { @@ -235,11 +235,11 @@ "help": "" }, "OpsiExplore": { - "name": "每月開荒+", + "name": "每月開荒Plus", "help": "" }, "OpsiShop": { - "name": "大世界商店+", + "name": "大世界商店Plus", "help": "" }, "OpsiVoucher": { @@ -247,7 +247,7 @@ "help": "" }, "OpsiDaily": { - "name": "大世界-每日+", + "name": "大世界每日Plus", "help": "" }, "OpsiObscure": { @@ -271,16 +271,16 @@ "help": "" }, "OpsiMeowfficerFarming": { - "name": "短貓相接+", + "name": "短貓相接", "help": "" }, "OpsiHazard1Leveling": { - "name": "侵蝕1練級+", + "name": "侵蝕1練級", "help": "" }, "OpsiScheduling": { - "name": "智慧排程+", - "help": "大世界智慧排程功能,用於協調侵蝕1練級與黃幣補充任務之間的切換\n啟用後將根據黃幣和行動力自動在不同任務之間切換並傳送推送通知" + "name": "智慧排程Plus", + "help": "大世界智慧排程Plus功能,開啟後無需重複開啟侵蝕1等任務,智慧排程Plus將全部接管" }, "OpsiPreventActionPointOverflow": { "name": "防止行動力溢出", @@ -575,7 +575,7 @@ "jp-17": "[JP] スイートバン" }, "ScreenshotMethod": { - "name": "模擬器截圖方案", + "name": "選擇截圖方案", "help": "使用自動選擇時,將執行一次效能測試並自動更改為最快的截圖方案\n一般情況下的速度: DroidCast_raw >> aScreenCap_nc > ADB_nc >>> aScreenCap > uiautomator2 ~= ADB\n執行 工具 - 效能測試 以尋找最快的方案", "auto": "自動選擇最快的", "ADB": "ADB", @@ -589,7 +589,7 @@ "ldopengl": "ldopengl" }, "ControlMethod": { - "name": "模擬器控制方案", + "name": "選擇控制方案", "help": "速度: minitouch > Hermit >>> uiautomator2 ~= ADB\n建議選minitouch,不建議選Hermit除非在vmos下", "ADB": "ADB", "uiautomator2": "uiautomator2", @@ -618,7 +618,7 @@ "help": "下列數值是根據Serial自動填充的,如果不懂請不要隨意修改,修改埠即可自動啟動對應例項" }, "Emulator": { - "name": "模擬器類型", + "name": "選擇模擬器類型", "help": "", "auto": "自動檢測", "NoxPlayer": "夜神模擬器", @@ -666,8 +666,8 @@ "help": "登入 SSH 的用戶名,例如: root" }, "RemoteSSHPublicKey": { - "name": "SSH 私鑰", - "help": "SSH 登入所需的私鑰內容(如果需要)" + "name": "SSH 公鑰", + "help": "SSH 登入所需的公鑰內容(如果需要)" }, "RemoteStartCommand": { "name": "遠端啟動指令", @@ -696,7 +696,7 @@ "help": "執行敏感任務出錯時停止AzurPilot,而不是重啟遊戲\n敏感任務包含隱秘海域、深淵海域、跨月每日" }, "SaveErrorCount": { - "name": "Error Log 儲存上限", + "name": "最多保存 X 個錯誤日誌", "help": "超出上限的錯誤日誌將被刪除,若為0或負值則沒有限制" }, "OnePushConfig": { @@ -712,7 +712,7 @@ "help": "在遊戲連續卡死(觸發GameStuckError)達到指定次數後,嘗試重啟模擬器。可能有助於解決模擬器假死或視訊記憶體溢位等問題。" }, "GameStuckThreshold": { - "name": "遊戲卡死重啟閾值", + "name": "遊戲卡死超過 X 秒後重啟", "help": "連續卡死多少次後觸發重啟。" }, "AdbOfflineRestart": { @@ -720,7 +720,7 @@ "help": "在連續檢測不到裝置(ADB離線或連線失敗)達到指定次數後,嘗試重啟模擬器。" }, "AdbOfflineThreshold": { - "name": "檢測不到裝置重啟閾值", + "name": "偵測不到裝置超過 X 秒後重啟", "help": "連續檢測不到裝置多少次後觸發重啟。" }, "LlmAnalysis": { @@ -934,7 +934,7 @@ "help": "" }, "RetireMode": { - "name": "退役模式", + "name": "選擇退役模式", "help": "若選擇強化,強化材料不足時回退到一鍵退役", "one_click_retire": "一鍵退役", "enhance": "強化", @@ -1024,7 +1024,7 @@ "help": "格式: \"cv > bb > ...\",\n留空則使用預設強化方式" }, "CheckPerCategory": { - "name": "每分類強化數量", + "name": "每個分類檢查 X 艘", "help": "每個艦船分類最多強化多少艦船,\n在戰鬥中的艦船會被跳過且不計入,\n優先判斷是否有強化材料" } }, @@ -1050,7 +1050,7 @@ "help": "" }, "RetireAmount": { - "name": "退役數量", + "name": "每次退役 X 艘", "help": "", "retire_all": "退役全部", "retire_10": "退役10個" @@ -1080,7 +1080,7 @@ "event_20200326_cn": "Microlayer Medley", "event_20200423_cn": "Crimson Echoes Rerun", "event_20200507_cn": "The Way Home in the Night", - "event_20200521_cn": "穹顶下的圣咏曲", + "event_20200521_cn": "穹頂下的聖詠曲", "event_20200521_en": "Iris of Light and Dark Rerun", "event_20200603_cn": "復刻峽灣間的反擊", "event_20200603_en": "Prelude under the Moon Rerun", @@ -1134,7 +1134,7 @@ "event_20230803_cn": "奏響鳶尾之歌", "event_20230817_cn": "愚者的天平", "event_20230914_cn": "須臾望月抄", - "event_20231026_cn": "飓風與青春之泉", + "event_20231026_cn": "颶風與青春之泉", "event_20231123_cn": "蒼閃忍法帖", "event_20231221_cn": "星海逐光", "event_20240229_cn": "雪境迷蹤", @@ -1159,7 +1159,7 @@ "event_20260326_cn": "漫遊者招募計劃", "event_20260417_cn": "假日航線閃耀海濱", "event_20260520_cn": "聖印前的同盟", - "event_20260625_cn": "美夢巡演奇秒夜", + "event_20260625_cn": "美夢巡演奇妙夜", "raid_20200624": "特別演習埃塞克斯級(復刻)", "raid_20210708": "復刻穿越彼方的水線", "raid_20220127": "演習神秘事件調查", @@ -1217,11 +1217,11 @@ "war_archives_20230223_cn": "檔案 湮燼塵墟", "war_archives_20230525_cn": "檔案 空相交會點", "war_archives_20230803_cn": "檔案 奏響鳶尾之歌", - "war_archives_20231026_cn": "檔案 飓風與青春之泉", + "war_archives_20231026_cn": "檔案 颶風與青春之泉", "war_archives_20240725_cn": "檔案 幻夢間奏曲" }, "Mode": { - "name": "關卡模式", + "name": "選擇關卡模式", "help": "僅適用於主線圖", "normal": "普通", "hard": "困難" @@ -1304,7 +1304,7 @@ "help": "" }, "DailyRunCount": { - "name": "每日出擊次數", + "name": "每日出擊 X 次", "help": "每天最多執行作戰檔案多少次。\n0 表示不限制。\n達到次數後會延遲到下一次伺服器刷新,不會關閉任務。" }, "DailyRunCountRemain": { @@ -1326,7 +1326,7 @@ "help": "" }, "Fleet1": { - "name": "一隊編號", + "name": "一隊使用第 X 支艦隊", "help": "", "1": "1 ", "2": "2 ", @@ -1343,7 +1343,7 @@ "diamond": "輪形陣" }, "Fleet1Mode": { - "name": "一隊自律模式", + "name": "一隊自律戰鬥模式", "help": "", "combat_auto": "自律戰鬥", "combat_manual": "手操", @@ -1360,7 +1360,7 @@ "5": "5 " }, "Fleet2": { - "name": "二隊編號", + "name": "二隊使用第 X 支艦隊", "help": "", "0": "不使用", "1": "1 ", @@ -1378,7 +1378,7 @@ "diamond": "輪形陣" }, "Fleet2Mode": { - "name": "二隊自律模式", + "name": "二隊自律戰鬥模式", "help": "", "combat_auto": "自律戰鬥", "combat_manual": "手操", @@ -1403,8 +1403,8 @@ "fleet1_standby_fleet2_all": "一隊待機二隊全清" }, "SkipPreparation": { - "name": "Fleet.SkipPreparation.name", - "help": "Fleet.SkipPreparation.help" + "name": "跳過編隊檢測", + "help": "開啟後將跳過編隊介面的艦隊選擇步驟,直接使用遊戲內當前預選的艦隊出擊\n適用於艦隊槽位未完全解鎖的帳號,避免下拉選單檢測卡死\n開啟前請確保遊戲內已手動選好艦隊1和艦隊2" } }, "Submarine": { @@ -1413,14 +1413,14 @@ "help": "" }, "Fleet": { - "name": "潛艇艦隊編號", + "name": "使用第 X 潛艇編隊", "help": "", "0": "不使用", "1": "1 ", "2": "2 " }, "Mode": { - "name": "潛艇出擊方案", + "name": "選擇潛艇出擊方案", "help": "僅在自律尋敵關閉的情況下生效,提醒: '狩獵及BOSS戰'為'僅狩獵'與'僅BOSS戰'的混合,它會在道中進行狩獵打擊,並在BOSS戰嘗試召喚潛艇。", "do_not_use": "不使用", "hunt_only": "僅狩獵", @@ -1429,14 +1429,14 @@ "every_combat": "每戰出擊" }, "AutoSearchMode": { - "name": "潛艇自律方案", + "name": "選擇潛艇自律方案", "help": "僅在自律尋敵下生效", "sub_standby": "待機", "sub_auto_call": "自動召喚潛艇" }, "DistanceToBoss": { "name": "BOSS戰前將潛艇移動到BOSS附近", - "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"及\"狩獵及BOSS戰\",時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522/達芬奇", + "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"及\"狩獵及BOSS戰\"時生效", "to_boss_position": "至 BOSS 所在位置", "1_grid_to_boss": "距離 BOSS 1 格", "2_grid_to_boss": "距離 BOSS 2 格", @@ -1581,7 +1581,7 @@ }, "C72MysteryFarming": { "_info": { - "name": "7-2 三站揀垃圾", + "name": "7-2 三戰揀垃圾", "help": "打三戰,揀完問號後撤退" }, "StepOnA3": { @@ -1722,7 +1722,7 @@ "help": "" }, "PtLimit": { - "name": "活動 PT 限制", + "name": "活動PT達到 X 後停止", "help": "當累計 PT 達到給定數值時,停用所有活動任務,0 表示不限制,使用自律尋敵時不能即時觸發\n開啟後,你只需要開啟所有活動任務,而不需要計算一共打多少次" }, "TimeLimit": { @@ -1740,7 +1740,7 @@ "help": "啟用後,將於每次出擊後檢查物資量" }, "CoinLimit": { - "name": "保持物資大於X", + "name": "保持物資大於 X", "help": "" }, "TaskCall": { @@ -1873,15 +1873,15 @@ }, "CoalitionScuttle": { "_info": { - "name": "CoalitionScuttle._info.name", - "help": "CoalitionScuttle._info.help" + "name": "聯盟沉船", + "help": "聯盟活動沉船設定,選擇被擊沉艦船位置" }, "Sacrifice": { - "name": "CoalitionScuttle.Sacrifice.name", - "help": "CoalitionScuttle.Sacrifice.help", - "vanguard": "vanguard", - "flagship": "flagship", - "vanguard_flagship": "vanguard_flagship" + "name": "犧牲船位置", + "help": "選擇被擊沉的艦船位置,前排/旗艦/前排+旗艦", + "vanguard": "前排", + "flagship": "旗艦", + "vanguard_flagship": "前排+旗艦" } }, "EventShop": { @@ -1982,19 +1982,19 @@ "help": "" }, "T4Allow": { - "name": "允許 T4 技能書溢位 X 點經驗", + "name": "允許T4技能書溢出 X 點經驗", "help": "" }, "T3Allow": { - "name": "允許 T3 技能書溢位 X 點經驗", + "name": "允許T3技能書溢出 X 點經驗", "help": "" }, "T2Allow": { - "name": "允許 T2 技能書溢位 X 點經驗", + "name": "允許T2技能書溢出 X 點經驗", "help": "" }, "T1Allow": { - "name": "允許 T1 技能書溢位 X 點經驗", + "name": "允許T1技能書溢出 X 點經驗", "help": "" } }, @@ -2012,7 +2012,7 @@ "help": "" }, "MinLevel": { - "name": "僅新增等級 >= X 的角色", + "name": "僅添加等級 >= X 的角色", "help": "" } }, @@ -2158,7 +2158,7 @@ "help": "" }, "Mode": { - "name": "訓練模式", + "name": "選擇訓練模式", "help": "無縫練貓將每隔 2.5 ~ 3.5 小時收取指揮貓\n每天收一隻意思是每天收一隻,週日收全部、強化並重新填充佇列,以完成每週任務", "seamlessly": "無縫練貓", "once_a_day": "每天收一隻" @@ -2277,7 +2277,7 @@ }, "Enable": { "name": "商店開關", - "help": "" + "help": "開啟後才會執行該商店,關閉後會在任務中跳過該商店" }, "UseGems": { "name": "使用鑽石", @@ -2311,7 +2311,7 @@ }, "Enable": { "name": "商店開關", - "help": "" + "help": "開啟後才會執行該商店,關閉後會在任務中跳過該商店" }, "Refresh": { "name": "重新整理商店", @@ -2428,7 +2428,7 @@ }, "Enable": { "name": "商店開關", - "help": "" + "help": "開啟後才會執行該商店,關閉後會在任務中跳過該商店" }, "Filter": { "name": "商店過濾器", @@ -2533,7 +2533,7 @@ "6": "六期科研" }, "ShipIndex": { - "name": "艦船序號", + "name": "選擇第 X 艘科研船", "help": "從左往右數,從 1 開始", "0": "不購買", "1": "1", @@ -2568,7 +2568,7 @@ "6": "六期科研" }, "ShipIndex": { - "name": "艦船序號", + "name": "選擇第 X 艘科研船", "help": "從左往右數,從1開始", "0": "不購買", "1": "1", @@ -2602,7 +2602,7 @@ "wishing_well": "祈願池" }, "Amount": { - "name": "抽卡數量", + "name": "每天建造 X 次", "help": "", "1": "1", "2": "2", @@ -2727,7 +2727,7 @@ "new_jersey": "紐澤西", "taihou": "大鳳", "aegir": "埃吉爾", - "nakhimov": "纳希莫夫" + "nakhimov": "納希莫夫" } }, "Daily": { @@ -2918,7 +2918,7 @@ "help": "推薦 1.0 ~ 3.0,因為人物立繪會遮擋血條,所以需要一定的時間確認血量是否真的低於閾值" }, "OpponentRefreshValue": { - "name": "今日演習對手已重新整理 X 次", + "name": "今日已重新整理對手 X 次", "help": "自動更新的數值,過0點重置\n如果你手動重新整理的對手,應該修改這個數值" }, "OpponentRefreshRecord": { @@ -2964,7 +2964,7 @@ "help": "每次進入倉庫的時候,使用所有的使用坐標記錄儀" }, "BuyActionPointLimit": { - "name": "買行動力X次", + "name": "購買行動力 X 次", "help": "買行動力的最大次數", "0": "不買", "1": "1次 (1000油, 100行動力)", @@ -2991,7 +2991,7 @@ }, "RepairPackThresholdHazard1": { "name": "任意艦船血量低於 X 後,使用維修箱修理(侵蝕1)", - "help": "僅在開啟“使用維修箱修船”時生效\n僅在「侵蝕1練級+」任務中嘗試維修\n0.0 ~ 1.0" + "help": "僅在開啟“使用維修箱修船”時生效\n僅在「侵蝕1練級Plus」任務中嘗試維修\n0.0 ~ 1.0" }, "DoRandomMapEvent": { "name": "完成地圖隨機事件", @@ -3085,7 +3085,7 @@ }, "OpsiExplore": { "_info": { - "name": "大世界開荒", + "name": "大世界開荒Plus", "help": "每月月初開荒大世界的所有海域,不需要買戰役資訊記錄儀(5000油道具)\n這個功能將每隔27分鐘清理一個海域,以保證空域搜尋冷卻\n使用此功能前必須滿足以下條件:\n- 通關大世界主線並完成模擬戰+塞壬試驗場" }, "SpecialRadar": { @@ -3111,7 +3111,7 @@ }, "OpsiShop": { "_info": { - "name": "大世界商店", + "name": "大世界商店Plus", "help": "使用此功能前必須滿足以下條件:\n- 通關大世界主線並完成模擬戰+塞壬試驗場\n- 啟用大世界開荒任務或使用戰役資訊記錄儀(5000油道具)\n- 通關大世界主線後的下一個月,即港口商店變為新版商店後才可使用" }, "PresetFilter": { @@ -3128,7 +3128,7 @@ "help": "使用自定義過濾器需將 \"港口商店過濾器\" 設定為 \"自定義\",並閱讀 https://github.com/LmeSzinc/AzurLaneAutoScript/wiki/filter_string_cn" }, "DisableBeforeDate": { - "name": "每月 X 號前,不購買大世界商店", + "name": "每月 X 號前,不購買大世界商店Plus", "help": "從指定日期的後一天開始,每天買一次大世界商店\n0 表示不限制,每天都購買" } }, @@ -3144,11 +3144,11 @@ }, "OpsiDaily": { "_info": { - "name": "大世界每日", + "name": "大世界每日Plus", "help": "使用此功能前必須滿足以下條件:\n- 通關大世界主線並完成模擬戰+塞壬試驗場\n- 啟用大世界開荒任務或使用戰役資訊記錄儀(5000油道具)" }, "DoMission": { - "name": "做大世界每日", + "name": "做大世界每日Plus", "help": "在港口接每日,並完成所有每日" }, "UseTuningSample": { @@ -3412,11 +3412,11 @@ }, "OpsiScheduling": { "_info": { - "name": "智慧排程", + "name": "智慧排程Plus", "help": "大世界智慧排程功能,用於協調侵蝕1練級與黃幣補充任務之間的切換\n啟用後將根據黃幣和行動力自動在不同任務之間切換並傳送推送通知\n\n原版 AzurPilot 的一些防呆限制已被解除,請謹慎設定引數" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "獲取 X 個作戰補給憑證", + "name": "保留 X 個作戰補給憑證", "help": "啟用後將使用下方設定的 獲取 X 個作戰補給憑證 的配置,停用則使用侵蝕1練級 黃幣保留配置\n關閉後將使用行動力控制\n開啟後使用黃幣控制", "True": "開啟", "False": "關閉" @@ -3430,7 +3430,7 @@ "help": "行動力低於 X 後停止,自動開啟行動力箱子,X 包含箱子中的行動力,建議保留1000行動力給侵蝕1練級" }, "OperationCoinsReturnThreshold": { - "name": "獲取 X 個作戰補給憑證", + "name": "作戰補給憑證低於 X 時返航", "help": "在黃幣獲取任務時 共獲取(增加) X 個作戰補給憑證後返回侵蝕1練級" }, "EnableMeowfficerFarming": { @@ -3691,7 +3691,7 @@ "help": "" }, "Positions": { - "name": "崗位數量", + "name": "安排 X 個礦工崗位", "help": "", "1": "1", "2": "2", @@ -3738,7 +3738,7 @@ "help": "" }, "Positions": { - "name": "崗位數量", + "name": "安排 X 個伐木工崗位", "help": "", "1": "1", "2": "2", @@ -3787,7 +3787,7 @@ "help": "餐品會按從1~8的順序依次生產,生產目標為餐品X數量選項,完全滿足前一項後才會執行下一項,後一項在運行時不會影響之前幾項任務的達成\n餐品1-8中的涼拌雙筍和蘆筍炒蝦仁會根據當前季節自動切換為對應季節餐品" }, "PostNumber": { - "name": "崗位數量", + "name": "安排 X 個廚師崗位", "help": "", "1": "1", "2": "2" @@ -3818,7 +3818,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber1": { - "name": "餐品1數量", + "name": "生產餐品1至 X 份", "help": "" }, "Meal2": { @@ -3839,7 +3839,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber2": { - "name": "餐品2數量", + "name": "生產餐品2至 X 份", "help": "" }, "Meal3": { @@ -3860,7 +3860,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber3": { - "name": "餐品3數量", + "name": "生產餐品3至 X 份", "help": "" }, "Meal4": { @@ -3881,7 +3881,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber4": { - "name": "餐品4數量", + "name": "生產餐品4至 X 份", "help": "" }, "Meal5": { @@ -3902,7 +3902,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber5": { - "name": "餐品5數量", + "name": "生產餐品5至 X 份", "help": "" }, "Meal6": { @@ -3923,7 +3923,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber6": { - "name": "餐品6數量", + "name": "生產餐品6至 X 份", "help": "" }, "Meal7": { @@ -3944,7 +3944,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber7": { - "name": "餐品7數量", + "name": "生產餐品7至 X 份", "help": "" }, "Meal8": { @@ -3965,7 +3965,7 @@ "onion_fish": "洋蔥蒸魚" }, "MealNumber8": { - "name": "餐品8數量", + "name": "生產餐品8至 X 份", "help": "" } }, @@ -3998,7 +3998,7 @@ "help": "餐品會按從1~8的順序依次生產,生產目標為餐品X數量選項,完全滿足前一項後才會執行下一項,後一項在運行時不會影響之前幾項任務的達成\n請手動製作蜂蜜製品\n餐品中的迎春花茶和鮮榨鳳梨汁會根據當前季節自動切換為對應季節餐品" }, "PostNumber": { - "name": "崗位數量", + "name": "安排 X 個調飲師崗位", "help": "", "1": "1", "2": "2" @@ -4027,7 +4027,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber1": { - "name": "餐品1數量", + "name": "生產餐品1至 X 份", "help": "" }, "Meal2": { @@ -4046,7 +4046,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber2": { - "name": "餐品2數量", + "name": "生產餐品2至 X 份", "help": "" }, "Meal3": { @@ -4065,7 +4065,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber3": { - "name": "餐品3數量", + "name": "生產餐品3至 X 份", "help": "" }, "Meal4": { @@ -4084,7 +4084,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber4": { - "name": "餐品4數量", + "name": "生產餐品4至 X 份", "help": "" }, "Meal5": { @@ -4103,7 +4103,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber5": { - "name": "餐品5數量", + "name": "生產餐品5至 X 份", "help": "" }, "Meal6": { @@ -4122,7 +4122,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber6": { - "name": "餐品6數量", + "name": "生產餐品6至 X 份", "help": "" }, "Meal7": { @@ -4141,7 +4141,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber7": { - "name": "餐品7數量", + "name": "生產餐品7至 X 份", "help": "" }, "Meal8": { @@ -4160,7 +4160,7 @@ "spring_flower_tea": "迎春花茶" }, "MealNumber8": { - "name": "餐品8數量", + "name": "生產餐品8至 X 份", "help": "" } }, @@ -4191,7 +4191,7 @@ "help": "餐品會按從1~8的順序依次生產,生產目標為餐品X數量選項,完全滿足前一項後才會執行下一項,後一項在運行時不會影響之前幾項任務的達成" }, "PostNumber": { - "name": "崗位數量", + "name": "安排 X 個燒烤師崗位", "help": "", "1": "1", "2": "2" @@ -4214,7 +4214,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber1": { - "name": "餐品1數量", + "name": "生產餐品1至 X 份", "help": "" }, "Meal2": { @@ -4231,7 +4231,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber2": { - "name": "餐品2數量", + "name": "生產餐品2至 X 份", "help": "" }, "Meal3": { @@ -4248,7 +4248,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber3": { - "name": "餐品3數量", + "name": "生產餐品3至 X 份", "help": "" }, "Meal4": { @@ -4265,7 +4265,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber4": { - "name": "餐品4數量", + "name": "生產餐品4至 X 份", "help": "" }, "Meal5": { @@ -4282,7 +4282,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber5": { - "name": "餐品5數量", + "name": "生產餐品5至 X 份", "help": "" }, "Meal6": { @@ -4299,7 +4299,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber6": { - "name": "餐品6數量", + "name": "生產餐品6至 X 份", "help": "" }, "Meal7": { @@ -4316,7 +4316,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber7": { - "name": "餐品7數量", + "name": "生產餐品7至 X 份", "help": "" }, "Meal8": { @@ -4333,7 +4333,7 @@ "double_energy": "能量雙拼套餐" }, "MealNumber8": { - "name": "餐品8數量", + "name": "生產餐品8至 X 份", "help": "" } }, @@ -4362,7 +4362,7 @@ "help": "餐品會按從1~8的順序依次生產,生產目標為餐品X數量選項,完全滿足前一項後才會執行下一項,後一項在運行時不會影響之前幾項任務的達成" }, "PostNumber": { - "name": "崗位數量", + "name": "安排 X 個簡餐師崗位", "help": "", "1": "1", "2": "2" @@ -4387,7 +4387,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber1": { - "name": "餐品1數量", + "name": "生產餐品1至 X 份", "help": "" }, "Meal2": { @@ -4406,7 +4406,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber2": { - "name": "餐品2數量", + "name": "生產餐品2至 X 份", "help": "" }, "Meal3": { @@ -4425,7 +4425,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber3": { - "name": "餐品3數量", + "name": "生產餐品3至 X 份", "help": "" }, "Meal4": { @@ -4444,7 +4444,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber4": { - "name": "餐品4數量", + "name": "生產餐品4至 X 份", "help": "" }, "Meal5": { @@ -4463,7 +4463,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber5": { - "name": "餐品5數量", + "name": "生產餐品5至 X 份", "help": "" }, "Meal6": { @@ -4482,7 +4482,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber6": { - "name": "餐品6數量", + "name": "生產餐品6至 X 份", "help": "" }, "Meal7": { @@ -4501,7 +4501,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber7": { - "name": "餐品7數量", + "name": "生產餐品7至 X 份", "help": "" }, "Meal8": { @@ -4520,7 +4520,7 @@ "seafood_rice": "海鮮飯" }, "MealNumber8": { - "name": "餐品8數量", + "name": "生產餐品8至 X 份", "help": "" } }, @@ -4551,7 +4551,7 @@ "help": "餐品會按從1~8的順序依次生產,生產目標為餐品X數量選項,完全滿足前一項後才會執行下一項,後一項在運行時不會影響之前幾項任務的達成" }, "PostNumber": { - "name": "崗位數量", + "name": "安排 X 個咖啡師崗位", "help": "", "1": "1", "2": "2" @@ -4579,7 +4579,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber1": { - "name": "餐品1數量", + "name": "生產餐品1至 X 份", "help": "" }, "Meal2": { @@ -4597,7 +4597,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber2": { - "name": "餐品2數量", + "name": "生產餐品2至 X 份", "help": "" }, "Meal3": { @@ -4615,7 +4615,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber3": { - "name": "餐品3數量", + "name": "生產餐品3至 X 份", "help": "" }, "Meal4": { @@ -4633,7 +4633,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber4": { - "name": "餐品4數量", + "name": "生產餐品4至 X 份", "help": "" }, "Meal5": { @@ -4651,7 +4651,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber5": { - "name": "餐品5數量", + "name": "生產餐品5至 X 份", "help": "" }, "Meal6": { @@ -4669,7 +4669,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber6": { - "name": "餐品6數量", + "name": "生產餐品6至 X 份", "help": "" }, "Meal7": { @@ -4687,7 +4687,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber7": { - "name": "餐品7數量", + "name": "生產餐品7至 X 份", "help": "" }, "Meal8": { @@ -4705,7 +4705,7 @@ "fruity_fruitier": "果香雙杯樂" }, "MealNumber8": { - "name": "餐品8數量", + "name": "生產餐品8至 X 份", "help": "" } }, @@ -4897,7 +4897,7 @@ "False": "禁用" }, "SeasonalThreshold": { - "name": "季節限定替換閾值", + "name": "季節限定商品數量低於 X 時替換", "help": "季節限定菜品庫存低於此數量時觸發替換" } }, @@ -5639,7 +5639,7 @@ "help": "目前版本搭建點擊流程:船塢、第一艘、快速更換、裝備中、佔位換裝邏輯、下一艘。" }, "ShipLimit": { - "name": "處理艦船上限", + "name": "處理前 X 艘艦船", "help": "0 表示一直處理到無法切換下一艘;除錯時可設定為 1 或其他較小數量。" }, "EnableSlot1": { @@ -5679,7 +5679,7 @@ "help": "測試哪個截圖方案和控制方案速度更快,速度因模擬器和電腦配置而異\n測試完成後,在 \"AzurPilot\" - \"模擬器設定\" 內選擇最快的截圖和控制方案" }, "DeviceType": { - "name": "裝置類型", + "name": "選擇裝置類型", "help": "", "emulator": "模擬器", "plone_cloud_with_adb": "雲手機 有公網ADB", From 6fced86d37511e1ba44417a2742ad1cc95c8da65 Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Sun, 26 Jul 2026 23:52:39 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alas.py | 34 +- module/auto_equip/auto_equip.py | 46 +-- module/awaken/assets.py | 2 + module/awaken/awaken.py | 113 +++++-- module/azur_stats/__init__.py | 1 + module/azur_stats/assets.py | 2 + module/azur_stats/image/__init__.py | 1 + module/azur_stats/image/auto_search_reward.py | 9 +- module/azur_stats/image/base.py | 7 + module/azur_stats/image/get_items.py | 9 +- module/azur_stats/image/opsi_reward.py | 7 + module/azur_stats/image/opsi_zone.py | 7 + module/azur_stats/scene/__init__.py | 1 + module/azur_stats/scene/base.py | 7 + module/azur_stats/scene/operation_siren.py | 7 + module/base/api_client.py | 36 +-- module/base/async_executor.py | 10 +- module/base/base.py | 16 +- module/base/button.py | 6 + module/base/decorator.py | 10 +- module/base/device_id.py | 8 +- module/base/filter.py | 8 +- module/base/mask.py | 6 + module/base/resource.py | 105 +++++- module/base/retry.py | 8 +- module/base/template.py | 6 + module/base/timer.py | 6 + module/base/utils.py | 6 + module/campaign/ambush_1_1.py | 28 +- module/campaign/assets.py | 2 + module/campaign/campaign_base.py | 133 +++++++- module/campaign/campaign_event.py | 69 ++-- module/campaign/campaign_ocr.py | 34 +- module/campaign/campaign_status.py | 32 +- module/campaign/campaign_ui.py | 126 +++++++- module/campaign/gems_farming.py | 262 +++++++++++++-- module/campaign/os_run.py | 32 +- module/campaign/run.py | 97 ++++-- module/coalition/assets.py | 2 + module/coalition/coalition.py | 63 +++- module/coalition/coalition_scuttle.py | 18 +- module/coalition/coalition_sp.py | 7 + module/coalition/combat.py | 11 +- module/coalition/ui.py | 23 +- module/combat/assets.py | 2 + module/combat/auto_search_combat.py | 68 ++-- module/combat/combat.py | 75 +++-- module/combat/combat_auto.py | 27 +- module/combat/combat_manual.py | 26 ++ module/combat/emotion.py | 98 ++++-- module/combat/hp_balancer.py | 43 ++- module/combat/level.py | 37 ++- module/combat/submarine.py | 33 +- module/combat_ui/assets.py | 2 + module/commission/assets.py | 2 + module/commission/commission.py | 244 +++++++++----- module/commission/preset.py | 4 + module/commission/project.py | 27 +- module/commission/project_data.py | 4 + module/config/code_generator.py | 6 + module/config/config.py | 90 ++++-- module/config/config_generated.py | 6 + module/config/config_manual.py | 22 ++ module/config/config_updater.py | 43 +++ module/config/deep.py | 6 + module/config/env.py | 7 + module/config/i18n/en-US.json | 45 +-- module/config/i18n/ja-JP.json | 152 +++++---- module/config/i18n/zh-CN.json | 24 +- module/config/i18n/zh-MIAO.json | 24 +- module/config/i18n/zh-TW.json | 24 +- module/config/mcp_helper.py | 28 ++ module/config/redirect_utils/os_handler.py | 7 + module/config/redirect_utils/shop_filter.py | 6 + module/config/redirect_utils/utils.py | 15 + module/config/task_priority.py | 6 + module/config/time_source.py | 18 +- module/config/utils.py | 19 +- module/config/watcher.py | 8 +- module/daemon/benchmark.py | 30 +- module/daemon/daemon.py | 56 ++++ module/daemon/daemon_base.py | 6 + module/daemon/game_manager.py | 10 +- module/daemon/ocr_benchmark.py | 52 +-- module/daemon/os_daemon.py | 10 +- module/daemon/uncensored.py | 26 +- module/daily/assets.py | 2 + module/daily/daily.py | 60 ++-- module/device/app_control.py | 70 +++- module/device/connection.py | 193 +++++------ module/device/connection_attr.py | 76 +++-- module/device/control.py | 80 ++++- module/device/device.py | 55 ++-- module/device/env.py | 3 + module/device/input.py | 27 ++ module/device/method/adb.py | 29 +- module/device/method/ascreencap.py | 34 +- module/device/method/droidcast.py | 44 +-- module/device/method/hermit.py | 25 +- module/device/method/ldopengl.py | 27 +- module/device/method/maatouch.py | 17 +- module/device/method/minitouch.py | 29 +- module/device/method/nemu_ipc.py | 41 ++- module/device/method/pool.py | 7 +- module/device/method/remove_warning.py | 3 + module/device/method/scrcpy/__init__.py | 2 + module/device/method/scrcpy/control.py | 3 + module/device/method/scrcpy/core.py | 31 +- module/device/method/scrcpy/options.py | 3 + module/device/method/scrcpy/scrcpy.py | 7 +- module/device/method/uiautomator_2.py | 28 +- module/device/method/utils.py | 11 +- module/device/method/wsa.py | 5 +- module/device/pkg_resources/__init__.py | 4 +- module/device/platform/__init__.py | 2 + module/device/platform/emulator_base.py | 3 + module/device/platform/emulator_mac.py | 3 + module/device/platform/emulator_windows.py | 3 + module/device/platform/platform_base.py | 95 +++--- module/device/platform/platform_mac.py | 43 +-- module/device/platform/platform_windows.py | 69 ++-- module/device/platform/utils.py | 3 + module/device/screenshot.py | 104 ++++-- module/dorm/assets.py | 2 + module/dorm/buy_furniture.py | 42 ++- module/dorm/dorm.py | 113 +++++-- module/equipment/assets.py | 2 + module/equipment/equipment.py | 54 +++- module/equipment/equipment_change.py | 22 +- module/equipment/equipment_code.py | 34 +- module/equipment/fleet_equipment.py | 6 +- module/event/assets.py | 2 + module/event/base.py | 13 + module/event/campaign_abcd.py | 28 +- module/event/campaign_sp.py | 25 +- module/event/maritime_escort.py | 13 +- module/event_hospital/assets.py | 2 + module/event_hospital/clue.py | 29 +- module/event_hospital/combat.py | 22 +- module/event_hospital/hospital.py | 68 ++-- module/event_hospital/hospital_event.py | 21 +- module/event_hospital/ui.py | 7 + module/eventstory/assets.py | 2 + module/eventstory/eventstory.py | 50 ++- module/exception.py | 121 ++++++- module/exercise/assets.py | 2 + module/exercise/combat.py | 65 +++- module/exercise/equipment.py | 16 + module/exercise/exercise.py | 124 +++++-- module/exercise/hp_daemon.py | 26 ++ module/exercise/opponent.py | 54 +++- module/freebies/assets.py | 2 + module/freebies/battle_pass.py | 14 +- module/freebies/data_key.py | 18 +- module/freebies/freebies.py | 27 +- module/freebies/mail_white.py | 78 ++++- module/freebies/supply_pack.py | 24 +- module/gacha/assets.py | 2 + module/gacha/gacha_reward.py | 18 +- module/gacha/ui.py | 13 +- module/game_setting/setting_extractor.py | 4 + module/game_setting/setting_generated.py | 4 + module/guild/assets.py | 2 + module/guild/base.py | 21 +- module/guild/guild_combat.py | 4 + module/guild/guild_reward.py | 4 + module/guild/lobby.py | 8 +- module/guild/logistics.py | 84 +++-- module/guild/operations.py | 78 ++--- module/handler/ambush.py | 29 +- module/handler/assets.py | 2 + module/handler/auto_search.py | 58 +++- module/handler/enemy_searching.py | 89 +++++- module/handler/fast_forward.py | 65 ++-- module/handler/info_handler.py | 52 ++- module/handler/login.py | 62 ++-- module/handler/mystery.py | 73 ++++- module/handler/sensitive_info.py | 35 +- module/handler/strategy.py | 31 +- module/hard/assets.py | 2 + module/hard/equipment.py | 7 + module/hard/hard.py | 23 +- module/island/assets.py | 2 + module/island/island.py | 8 +- module/island/island_air_drop.py | 5 + module/island/island_business.py | 5 + module/island/island_cargo_preparation.py | 27 +- module/island/island_daily_gather.py | 5 + module/island/island_daily_interact.py | 15 +- module/island/island_daily_order.py | 7 +- module/island/island_farm.py | 98 +++++- module/island/island_fishery.py | 5 + module/island/island_grill.py | 7 +- module/island/island_juu_coffee.py | 5 + module/island/island_juu_eatery.py | 5 + module/island/island_manufacture.py | 5 + module/island/island_mine_forest.py | 5 + module/island/island_pearl_sell.py | 21 +- module/island/island_rancher.py | 5 + module/island/island_restaurant.py | 5 + module/island/island_select_character.py | 5 + module/island/island_shop_base.py | 5 + module/island/island_teahouse.py | 5 + module/island/ui.py | 57 +++- module/island/warehouse.py | 5 + module/island_business/assets.py | 2 + module/island_cargo_preparation/assets.py | 2 + module/island_daily_interact/__init__.py | 2 +- module/island_daily_interact/assets.py | 2 + module/island_daily_order/assets.py | 2 + module/island_farm/assets.py | 2 + module/island_fishery/__init__.py | 1 + module/island_fishery/assets.py | 2 + module/island_grill/assets.py | 2 + module/island_juu_coffee/assets.py | 2 + module/island_juu_eatery/assets.py | 2 + module/island_manufacture/assets.py | 2 + module/island_mine_forest/assets.py | 2 + module/island_pearl_sell/assets.py | 2 + module/island_rancher/assets.py | 2 + module/island_restaurant/assets.py | 2 + module/island_select_character/assets.py | 2 + module/island_teahouse/assets.py | 2 + module/llm.py | 19 ++ module/log_res/__init__.py | 2 + module/log_res/log_res.py | 26 +- module/logger.py | 25 +- module/map/assets.py | 2 + module/map/camera.py | 111 ++++--- module/map/fleet.py | 150 +++++---- module/map/map.py | 161 ++++++---- module/map/map_base.py | 291 +++++++++++++++-- module/map/map_fleet_preparation.py | 39 ++- module/map/map_grids.py | 263 ++++++++++----- module/map/map_operation.py | 102 ++++-- module/map/utils.py | 122 +++++-- module/map_detection/detector.py | 3 + module/map_detection/detector_example.py | 3 + module/map_detection/grid.py | 3 + module/map_detection/grid_info.py | 3 + module/map_detection/grid_predictor.py | 3 + module/map_detection/homography.py | 15 +- module/map_detection/os_grid.py | 3 + module/map_detection/perspective.py | 15 +- module/map_detection/utils.py | 20 ++ module/map_detection/utils_assets.py | 3 + module/map_detection/view.py | 17 +- module/memory_profiler.py | 7 + module/meowfficer/assets.py | 2 + module/meowfficer/base.py | 6 +- module/meowfficer/buy.py | 68 ++-- module/meowfficer/collect.py | 41 ++- module/meowfficer/enhance.py | 64 +++- module/meowfficer/fort.py | 6 +- module/meowfficer/meowfficer.py | 26 ++ module/meowfficer/train.py | 56 +++- module/meta_reward/assets.py | 2 + module/meta_reward/meta_reward.py | 125 ++++++-- module/minigame/assets.py | 2 + module/minigame/minigame.py | 84 ++++- module/minigame/new_year_challenge.py | 4 + module/notify/__init__.py | 2 + module/notify/notify.py | 26 +- module/ocr/al_ocr.py | 95 +++++- module/ocr/models.py | 34 ++ module/ocr/ncnn_ocr.py | 25 +- module/ocr/ocr.py | 9 +- module/ocr/rpc.py | 14 +- module/ocr/windows_ml.py | 22 ++ module/os/assets.py | 2 + module/os/camera.py | 41 ++- module/os/config.py | 30 +- module/os/dock_mixin.py | 12 + module/os/fleet.py | 276 ++++++++++++---- module/os/globe_camera.py | 111 +++++-- module/os/globe_detection.py | 85 ++++- module/os/globe_operation.py | 54 +++- module/os/globe_zone.py | 33 ++ module/os/map.py | 279 +++++++++------- module/os/map_base.py | 21 ++ module/os/map_data.py | 11 + module/os/map_fleet_selector.py | 30 +- module/os/map_operation.py | 67 +++- module/os/operation_siren.py | 32 +- module/os/radar.py | 60 +++- module/os/sea_miles_ocr.py | 14 +- module/os/ship_exp.py | 29 +- module/os/ship_exp_data.py | 11 + module/os/tasks/abyssal.py | 16 +- module/os/tasks/archive.py | 12 +- module/os/tasks/coin_task_mixin.py | 11 + module/os/tasks/cross_month.py | 16 +- module/os/tasks/daily.py | 17 +- module/os/tasks/explore.py | 25 +- module/os/tasks/fleet_auto_change.py | 12 + module/os/tasks/hazard_leveling.py | 14 +- module/os/tasks/meowfficer_farming.py | 18 +- module/os/tasks/month_boss.py | 15 +- module/os/tasks/obscure.py | 14 +- .../os/tasks/prevent_action_point_overflow.py | 17 +- module/os/tasks/shop.py | 18 +- module/os/tasks/stronghold.py | 12 +- module/os/tasks/voucher.py | 13 +- module/os_ash/ash.py | 37 ++- module/os_ash/assets.py | 2 + module/os_ash/meta.py | 6 + module/os_combat/assets.py | 2 + module/os_combat/combat.py | 28 +- module/os_handler/action_point.py | 49 +-- module/os_handler/assets.py | 2 + module/os_handler/enemy_searching.py | 8 +- module/os_handler/map_event.py | 10 +- module/os_handler/map_order.py | 14 +- module/os_handler/mission.py | 37 ++- module/os_handler/os_status.py | 18 +- module/os_handler/port.py | 41 ++- module/os_handler/storage.py | 48 +-- module/os_handler/strategic.py | 38 ++- module/os_handler/target.py | 26 +- module/os_handler/target_data.py | 6 + module/os_shop/akashi_shop.py | 6 + module/os_shop/assets.py | 2 + module/os_shop/item.py | 6 + module/os_shop/port_shop.py | 22 +- module/os_shop/shop.py | 56 +++- module/os_shop/ui.py | 6 + module/os_simulator/constants.py | 6 + module/os_simulator/logger.py | 6 + module/os_simulator/plotter.py | 6 + module/os_simulator/simulator.py | 6 + module/private_quarters/assets.py | 2 + module/private_quarters/clerk.py | 17 +- module/private_quarters/interact.py | 47 +-- module/private_quarters/private_quarters.py | 71 +++- module/private_quarters/shop.py | 13 +- module/private_quarters/status.py | 9 + module/private_quarters/ui.py | 9 + module/raid/assets.py | 2 + module/raid/combat.py | 4 + module/raid/daily.py | 32 +- module/raid/raid.py | 97 +++++- module/raid/run.py | 17 +- module/raid/scuttle.py | 20 +- module/research/assets.py | 2 + module/research/preset.py | 4 + module/research/preset_generator.py | 4 + module/research/project.py | 302 ++++++++++++++---- module/research/project_data.py | 2 + module/research/research.py | 187 ++++++++--- module/research/rqueue.py | 48 ++- module/research/selector.py | 105 +++++- module/research/series.py | 51 ++- module/research/ui.py | 95 +++++- module/retire/assets.py | 2 + module/retire/dock.py | 32 +- module/retire/enhancement.py | 54 +++- module/retire/retirement.py | 104 +++--- module/retire/scanner.py | 78 ++++- module/retire/setting.py | 20 ++ module/reward/assets.py | 2 + module/reward/reward.py | 30 +- module/server_checker.py | 48 ++- module/shipyard/assets.py | 2 + module/shipyard/shipyard_reward.py | 81 ++++- module/shipyard/ui.py | 31 +- module/shipyard/ui_globals.py | 4 + module/shop/assets.py | 2 + module/shop/base.py | 72 +++-- module/shop/clerk.py | 21 +- module/shop/shop_core.py | 8 +- module/shop/shop_general.py | 22 +- module/shop/shop_guild.py | 10 +- module/shop/shop_medal.py | 33 +- module/shop/shop_merit.py | 8 +- module/shop/shop_reward.py | 14 +- module/shop/shop_select_globals.py | 4 + module/shop/shop_status.py | 17 + module/shop/shop_voucher.py | 36 ++- module/shop/ui.py | 12 +- module/shop_event/assets.py | 2 + module/shop_event/clerk.py | 36 ++- module/shop_event/item.py | 12 +- module/shop_event/selector.py | 10 + module/shop_event/shop_event.py | 101 +++--- module/shop_event/ui.py | 25 +- module/sos/assets.py | 2 + module/sos/sos.py | 55 +++- module/statistics/assets.py | 2 + module/statistics/azurstats.py | 74 ++++- module/statistics/battle_status.py | 6 + module/statistics/campaign_bonus.py | 7 + module/statistics/cl1_database.py | 12 +- module/statistics/drop_statistics.py | 43 ++- module/statistics/get_items.py | 6 + module/statistics/item.py | 17 +- module/statistics/opsi_month.py | 4 + module/statistics/opsi_runtime.py | 48 +-- module/statistics/resource_stats.py | 8 +- module/statistics/ship_exp_stats.py | 20 +- module/statistics/utils.py | 6 + module/storage/assets.py | 2 + module/storage/box_disassemble.py | 30 +- module/storage/storage.py | 100 +++--- module/storage/ui.py | 22 +- module/submodule/submodule.py | 4 + module/submodule/utils.py | 4 + module/tactical/assets.py | 2 + module/tactical/tactical_class.py | 135 +++++--- module/template/assets.py | 2 + module/ui/assets.py | 2 + module/ui/navbar.py | 17 +- module/ui/page.py | 33 ++ module/ui/scroll.py | 3 + module/ui/setting.py | 5 +- module/ui/switch.py | 7 +- module/ui/ui.py | 111 ++++--- module/ui_white/assets.py | 2 + module/war_archives/assets.py | 2 + module/war_archives/dictionary.py | 4 + module/war_archives/war_archives.py | 31 +- module/webui/__init__.py | 2 + module/webui/api.py | 23 +- module/webui/app.py | 21 +- module/webui/app_event_tools.py | 2 +- module/webui/app_home.py | 4 +- module/webui/app_lifecycle.py | 4 +- module/webui/app_stat_commission.py | 2 +- module/webui/app_task_config.py | 6 +- module/webui/base.py | 7 + module/webui/config.py | 7 + module/webui/dashboard_utils.py | 7 + module/webui/deploy_settings.py | 7 + module/webui/discord_presence.py | 7 + module/webui/event_calculator.py | 10 +- module/webui/fake_pil_module.py | 7 + module/webui/lang.py | 7 + module/webui/launcher.py | 4 + module/webui/oobe.py | 7 + module/webui/patch.py | 2 +- module/webui/process_manager.py | 37 ++- module/webui/remote_access.py | 66 ++-- module/webui/setting.py | 4 + module/webui/translate.py | 4 + module/webui/updater.py | 51 +-- module/webui/utils.py | 22 +- module/webui/widgets.py | 4 + 446 files changed, 10326 insertions(+), 3289 deletions(-) diff --git a/alas.py b/alas.py index 0db16ab30..a17383f13 100644 --- a/alas.py +++ b/alas.py @@ -342,7 +342,7 @@ def run(self, command, skip_first_screenshot=False): if self.config.Error_GameStuckRestart: self.consecutive_game_stuck += 1 limit = int(self.config.Error_GameStuckThreshold) - logger.warning(f'GameStuckError: {self.consecutive_game_stuck}/{limit}') + logger.warning(f'[Alas] GameStuckError: {self.consecutive_game_stuck}/{limit}') if self.consecutive_game_stuck >= limit: logger.warning('[Alas] 游戏卡住次数过多,正在重启模拟器...') if self._try_restart_emulator(): @@ -590,7 +590,7 @@ def save_error_log(self): image = handle_sensitive_image(data['image']) save_image(image, f'{folder}/{image_time}.png') except Exception as e: - logger.error(f"Save error screenshot failed: {e}") + logger.error(f"[Alas] 保存错误截图失败: {e}") try: with open(logger.log_file, 'r', encoding='utf-8') as f: @@ -605,7 +605,7 @@ def save_error_log(self): with open(f'{folder}/log.txt', 'w', encoding='utf-8') as f: f.writelines(lines) except Exception as e: - logger.error(f"Save error logs failed: {e}") + logger.error(f"[Alas] 保存错误日志失败: {e}") self.keep_last_errlog(config_folder, getattr(self.config, 'Error_SaveErrorCount', 0)) @@ -1064,7 +1064,7 @@ def emulator_manager(self): # 回退到 EmulatorManager 配置 enable = deep_get(self.config.data, 'EmulatorManager.EmulatorManager.EnableRemoteSSH', False) if not enable: - logger.warning('Remote SSH is not enabled in EmulatorManager settings.') + logger.warning('[Alas-SSH] 模拟器管理器设置中未启用远程SSH') return host = deep_get(self.config.data, 'EmulatorManager.EmulatorManager.RemoteSSHHost', '') @@ -1076,10 +1076,10 @@ def emulator_manager(self): key = deep_get(self.config.data, 'EmulatorManager.EmulatorManager.RemoteSSHPublicKey', '') if not host or not command: - logger.warning(f'RemoteSSHHost ({host}) or RemoteStartCommand ({command}) is empty, skip remote SSH command') + logger.warning(f'[Alas-SSH] 远程SSH主机 ({host}) 或远程启动命令 ({command}) 为空,跳过远程SSH命令') return - logger.hr('Remote SSH Command', level=1) + logger.hr('远程SSH命令', level=1) target = f'{user}@{host}' if user else host clear_ssh_host_key(host, port) # -n: 禁用标准输入 -T: 禁用伪终端分配 BatchMode: 避免密码提示导致挂起 @@ -1109,12 +1109,12 @@ def emulator_manager(self): os.chmod(key_file, 0o600) cmd += ['-i', key_file] - logger.info(f'Using provided private key for authentication') + logger.info(f'[Alas-SSH] 使用提供的私钥进行认证') except Exception as e: - logger.error(f'Failed to create or secure temporary key file: {e}') + logger.error(f'[Alas-SSH] 创建或保护临时密钥文件失败: {e}') cmd += [target, command] - logger.info(f'Executing remote command: {" ".join(cmd)}') + logger.info(f'[Alas-SSH] 执行远程命令: {" ".join(cmd)}') try: process = subprocess.Popen( @@ -1136,8 +1136,8 @@ def collect_stderr(): def collect_stdout(): for line in process.stdout: - logger.info(f'Remote: {line.strip()}') - + logger.info(f'[Alas-SSH] 远程输出: {line.strip()}') + stderr_thread = threading.Thread(target=collect_stderr) stdout_thread = threading.Thread(target=collect_stdout) stderr_thread.start() @@ -1148,20 +1148,20 @@ def collect_stdout(): process.wait(timeout=30) except subprocess.TimeoutExpired: process.kill() - logger.error('Remote SSH command timed out after 30 seconds') + logger.error('[Alas-SSH] 远程SSH命令超时(30秒)') return finally: stderr_thread.join(timeout=5) stdout_thread.join(timeout=5) if process.returncode == 0: - logger.info('Remote command executed successfully') + logger.info('[Alas-SSH] 远程命令执行成功') else: - logger.error(f'Remote command failed with return code {process.returncode}') + logger.error(f'[Alas-SSH] 远程命令失败,返回码 {process.returncode}') for line in stderr_content: - logger.error(f'Remote Error: {line}') + logger.error(f'[Alas-SSH] 远程错误: {line}') except Exception as e: - logger.error(f'Failed to execute remote SSH command: {e}') + logger.error(f'[Alas-SSH] 执行远程SSH命令失败: {e}') finally: if key_file and os.path.exists(key_file): try: @@ -1456,7 +1456,7 @@ def loop(self): from module.llm import analyze_exception analyze_exception(self.config, e) except Exception as ex: - logger.error(f'LLM Analysis failed: {ex}') + logger.error(f'[Alas] LLM错误分析失败: {ex}') logger.warning( f">>> 这是第 {consecutive_global_failures} 次连续全局失败,共 {MAX_GLOBAL_FAILURES} 次。" diff --git a/module/auto_equip/auto_equip.py b/module/auto_equip/auto_equip.py index 642d45d5b..5d4269fb3 100644 --- a/module/auto_equip/auto_equip.py +++ b/module/auto_equip/auto_equip.py @@ -1,3 +1,7 @@ +"""自动装备模块,提供舰船装备的快速更换功能。 +通过 OCR 识别和模板匹配,在船坞界面自动完成装备筛选、选择和装备操作, +支持批量更换和按方案配装。""" + from functools import lru_cache from pathlib import Path @@ -142,7 +146,7 @@ def _quick_change_appear(self): return self.appear(AUTO_EQUIP_QUICK_CHANGE_CHECK) def _open_quick_change(self): - logger.info('Open quick equipment change') + logger.info('打开快速换装') for _ in self.loop(timeout=10): if self._quick_change_appear(): return @@ -160,11 +164,11 @@ def _open_quick_change(self): def _quick_equipping_set(self, enable=True): target = 'on' if enable else 'off' current = auto_equip_equipping_filter.get(main=self) - logger.attr('Auto_equip_equipping_filter', current) + logger.attr('自动装备筛选', current) if current == target: return if current == 'unknown': - logger.warning('Unable to determine quick equipping filter state') + logger.warning('无法确定快速换装筛选状态') return self.device.click(AUTO_EQUIP_EQUIPPING_CLICK) @@ -175,7 +179,7 @@ def _auto_equip_click_record_clear(self): self.device.click_record_remove(name) def _quick_change_next(self): - logger.info('Swipe to next ship') + logger.info('滑动到下一艘舰船') self._auto_equip_click_record_clear() self.device.swipe_vector( vector=(-SWIPE_DISTANCE, 0), @@ -209,7 +213,7 @@ def _enabled_equipment_slots(self): if getattr(self.config, f'AutoEquip_EnableSlot{index}', True): slots.append(slot) - logger.attr('Enabled equipment slots', [slot.name for slot in slots]) + logger.attr('启用的装备槽', [slot.name for slot in slots]) return slots def _quick_empty_equipment_slots(self): @@ -221,7 +225,7 @@ def _quick_empty_equipment_slots(self): if score >= AUTO_EQUIP_EMPTY_SLOT_PLUS_SIMILARITY: empty_slots.append(slot) - logger.attr('Empty slot plus scores', scores) + logger.attr('空槽加号得分', scores) return empty_slots @staticmethod @@ -234,7 +238,7 @@ def _warehouse_no_equipment_score(image): def _warehouse_no_equipment(self): score = self._warehouse_no_equipment_score(self.device.image) - logger.attr('No equipment score', f'{score:.3f}') + logger.attr('无装备得分', f'{score:.3f}') return score >= AUTO_EQUIP_NO_EQUIPMENT_SIMILARITY def _quick_fill_slot_from_warehouse(self, slot): @@ -242,21 +246,21 @@ def _quick_fill_slot_from_warehouse(self, slot): self.wait_until_stable(AUTO_EQUIP_WAREHOUSE_FIRST) self.device.screenshot() if self._warehouse_no_equipment(): - logger.info(f'No equipment available for {slot.name}') + logger.info(f'[自动装备] {slot.name} 无可用装备') return False if self._warehouse_first_unavailable(self.device.image): - logger.info(f'Fill {slot.name} from warehouse second equipment') + logger.info(f'[自动装备] 从仓库第二件装备填充 {slot.name}') self.device.click(AUTO_EQUIP_WAREHOUSE_SECOND) else: - logger.info(f'Fill {slot.name} from warehouse first equipment') + logger.info(f'[自动装备] 从仓库第一件装备填充 {slot.name}') self.device.click(AUTO_EQUIP_WAREHOUSE_FIRST) self.device.sleep(AUTO_EQUIP_AFTER_EQUIP_WAIT) self.wait_until_stable(AUTO_EQUIP_EQUIPMENT_SLOT_ROW) return True def _fill_current_ship_equipment(self): - logger.hr('Auto equip current ship', level=2) + logger.hr('自动换装当前舰船', level=2) self._auto_equip_click_record_clear() try: self._open_quick_change() @@ -266,12 +270,12 @@ def _fill_current_ship_equipment(self): self._auto_equip_click_record_clear() def equipment_change_logic(self): - logger.info('Fill empty equipment slots') + logger.info('填充空装备槽') filled = 0 skipped = 0 self.device.screenshot() empty_slots = self._quick_empty_equipment_slots() - logger.attr('Empty equipment slots', [slot.name for slot in empty_slots]) + logger.attr('空装备槽', [slot.name for slot in empty_slots]) for slot in empty_slots: if self._should_stop(): @@ -282,8 +286,8 @@ def equipment_change_logic(self): else: skipped += 1 - logger.attr('Filled equipment slots', filled) - logger.attr('Skipped empty slots', skipped) + logger.attr('已填充装备槽', filled) + logger.attr('跳过的空槽', skipped) def _ship_limit(self): value = getattr(self.config, 'AutoEquip_ShipLimit', 0) @@ -294,15 +298,15 @@ def _ship_limit(self): return max(value, 0) def run(self): - logger.hr('Auto Equip', level=1) + logger.hr('自动换装', level=1) limit = self._ship_limit() - logger.attr('Ship limit', 'manual stop' if limit == 0 else limit) + logger.attr('舰船上限', '手动停止' if limit == 0 else limit) if limit == 0: - logger.warning('Ship limit is 0, AutoEquip will continue until manually stopped') + logger.warning('舰船上限为0,自动换装将持续到手动停止') self.ui_ensure(page_dock) if not self.dock_enter_first(non_npc=True): - logger.info('No ship to equip') + logger.info('无舰船可换装') return count = 0 @@ -311,11 +315,11 @@ def run(self): raise TaskEnd('AutoEquip stopped') count += 1 - logger.attr('Ship', count) + logger.attr('舰船', count) self._fill_current_ship_equipment() if limit and count >= limit: - logger.info('Reached ship limit') + logger.info('达到舰船上限') break self._quick_change_next() diff --git a/module/awaken/assets.py b/module/awaken/assets.py index 6eeca9619..4a2598179 100644 --- a/module/awaken/assets.py +++ b/module/awaken/assets.py @@ -1,3 +1,5 @@ +"""觉醒模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/awaken/awaken.py b/module/awaken/awaken.py index 8e5ec5ede..9f92de423 100644 --- a/module/awaken/awaken.py +++ b/module/awaken/awaken.py @@ -1,3 +1,33 @@ +""" +觉醒(Awaken)模块。 + +自动化舰船觉醒流程,将舰船等级从 100 级提升至 120 级或 125 级。 + +主要功能: + - 检测舰船当前等级(100~125) + - 判断觉醒所需资源(金币、心智芯片、心智阵列)是否充足 + - 执行单次觉醒操作,包括确认、等待动画完成 + - 对单艘舰船循环觉醒直到等级上限或资源不足 + - 遍历船坞中所有可觉醒舰船,直到资源耗尽 + +觉醒等级机制: + - 普通觉醒:消耗金币 + 心智芯片,等级上限 120 + - 觉醒+(觉醒拟合):额外消耗心智阵列,等级上限 125 + - 优先执行觉醒+(使用心智阵列),再执行普通觉醒(使用心智芯片) + +资源判断逻辑: + - 通过按钮匹配和红字颜色检测判断资源是否充足 + - COST_ARRAY 不存在时,COST_COIN 和 COST_CHIP 按钮会右移 54px + - 需要根据按钮位移情况验证结果有效性 + +继承关系: + 继承自 Dock(船坞操作),使用船坞过滤器筛选可觉醒舰船。 + +Pages: + 觉醒页面:is_in_awaken + 船坞页面:page_dock +""" + from module.awaken.assets import * from module.base.timer import Timer from module.exception import ScriptError @@ -9,15 +39,40 @@ class ShipLevel(Digit): + """ + 舰船等级 OCR 识别器。 + + 对标准 Digit OCR 进行后处理,只接受 100~125 范围内的等级值。 + 超出范围的识别结果被视为无效并返回 0。 + """ def after_process(self, result): result = super().after_process(result) if result < 100 or result > 125: - logger.warning('Unexpected ship level') + logger.warning('[觉醒] 异常的舰船等级') result = 0 return result class Awaken(Dock): + """ + 觉醒任务处理器。 + + 管理舰船觉醒的完整流程,包括资源检测、觉醒执行和船坞遍历。 + 继承自 Dock 以使用船坞过滤、排序和舰船选择功能。 + + 核心流程: + 1. 导航至船坞,按收藏和等级过滤可觉醒舰船 + 2. 进入舰船详情,执行觉醒直到等级上限或资源不足 + 3. 退出舰船详情,继续下一艘 + 4. 无可觉醒舰船或资源耗尽时结束 + + 属性: + 无额外实例属性,所有状态通过方法参数和返回值传递 + + 配置项: + Awaken_LevelCap: 觉醒等级上限,'level120' 或 'level125' + Awaken_Favourite: 是否仅觉醒收藏舰船 + """ def _get_button_state(self, button: Button): """ 获取指定资源按钮的状态。 @@ -58,7 +113,7 @@ def _get_awaken_cost(self, use_array=False): chip = self._get_button_state(COST_CHIP) array = self._get_button_state(COST_ARRAY) - logger.attr('AwakenCost', {'coin': coin, 'chip': chip, 'array': array}) + logger.attr('觉醒消耗', {'coin': coin, 'chip': chip, 'array': array}) def is_right_moved(button): # 如果 COST_ARRAY 不存在,COST_COIN 和 COST_CHIP 会右移 54px @@ -67,23 +122,23 @@ def is_right_moved(button): # 检查结果是否有效 if array is not None: if not use_array: - logger.warning('Not going to use array but array presents') + logger.warning('[觉醒] 不使用阵列但阵列存在') return 'unexpected_array' # 如果需要阵列,金币和芯片应该同时存在 if coin is not None and not is_right_moved(COST_COIN) \ and chip is not None and not is_right_moved(COST_CHIP): result = coin and chip and array - logger.attr('AwakenSufficient', result) + logger.attr('觉醒资源充足', result) return result else: # 如果不需要阵列,金币和芯片应该同时存在且右移 if coin is not None and is_right_moved(COST_COIN) \ and chip is not None and is_right_moved(COST_CHIP): result = coin and chip - logger.attr('AwakenSufficient', result) + logger.attr('觉醒资源充足', result) return result - logger.warning('Invalid awaken cost') + logger.warning('[觉醒] 无效的觉醒消耗') return 'invalid' def handle_awaken_finish(self): @@ -93,7 +148,7 @@ def is_in_awaken(self): return SHIP_LEVEL_CHECK.match_luma(self.device.image, similarity=0.7) def awaken_popup_close(self, skip_first_screenshot=True): - logger.info('Awaken popup close') + logger.info('[觉醒] 觉醒弹窗关闭') self.interval_clear(AWAKEN_CANCEL) while 1: if skip_first_screenshot: @@ -123,7 +178,7 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): in: is_in_awaken out: is_in_awaken """ - logger.hr('Awaken once', level=2) + logger.hr('觉醒一次', level=2) interval = Timer(3, count=6) while 1: if skip_first_screenshot: @@ -134,7 +189,7 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): if self.appear(AWAKEN_CONFIRM): break if LEVEL_UP.match_luma(self.device.image): - logger.info(f'awaken_once ended at {LEVEL_UP}') + logger.info(f'[觉醒] 觉醒一次在 {LEVEL_UP} 结束') return 'no_exp' # 由于随机背景,降低相似度阈值 if interval.reached() and AWAKENING.match_luma(self.device.image, similarity=0.7): @@ -142,7 +197,7 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): interval.reset() continue - logger.info('Get awaken cost') + logger.info('[觉醒] 获取觉醒消耗') timeout = Timer(2, count=6).start() skip_first_screenshot = True while 1: @@ -157,7 +212,7 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): self.awaken_popup_close() return result elif result is False: - logger.info('Insufficient resources to awaken') + logger.info('[觉醒] 资源不足无法觉醒') self.awaken_popup_close() return 'insufficient' elif result is True: @@ -169,12 +224,12 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): else: raise ScriptError(f'Unexpected _get_awaken_cost result: {result}') if timeout.reached(): - logger.warning('Get awaken cost timeout') + logger.warning('[觉醒] 获取觉醒消耗超时') self.awaken_popup_close() return 'timeout' # 资源充足,确认觉醒 - logger.info('Awaken confirm') + logger.info('[觉醒] 觉醒确认') self.interval_clear(AWAKEN_CONFIRM) # 觉醒弹窗在经验足够时需要 10 秒才出现,点击关闭需要 2 秒 # 因此此处超时设置较长 @@ -189,11 +244,11 @@ def awaken_once(self, use_array=False, skip_first_screenshot=True): # 结束条件 if timeout.reached(): - logger.warning('Awaken confirm timeout') + logger.warning('[觉醒] 觉醒确认超时') self.awaken_popup_close() break if finished and self.is_in_awaken(): - logger.info('Awaken finished') + logger.info('[觉醒] 觉醒完成') break # 点击操作 if self.appear_then_click(AWAKEN_CONFIRM, offset=(20, 20), interval=3): @@ -231,7 +286,7 @@ def get_ship_level(self, skip_first_screenshot=True): if level > 0: return level if timeout.reached(): - logger.warning('get_ship_level timeout') + logger.warning('[觉醒] 获取舰船等级超时') return level def awaken_ship(self, use_array=False, skip_first_screenshot=True): @@ -249,8 +304,8 @@ def awaken_ship(self, use_array=False, skip_first_screenshot=True): in: is_in_awaken out: is_in_awaken """ - logger.hr('Awaken ship', level=1) - logger.info(f'Awaken ship, use_array={use_array}') + logger.hr('觉醒舰船', level=1) + logger.info(f'[觉醒] 觉醒舰船, 使用阵列={use_array}') if use_array: stop_level = 125 @@ -264,7 +319,7 @@ def awaken_ship(self, use_array=False, skip_first_screenshot=True): level = self.get_ship_level() if level > 0: if level >= stop_level: - logger.info(f'Awaken ship ended at stop_level') + logger.info(f'[觉醒] 觉醒舰船在停止等级结束') return 'level_max' else: result = self.awaken_once(use_array) @@ -286,7 +341,7 @@ def awaken_ship(self, use_array=False, skip_first_screenshot=True): return 'timeout' # 错误,请求退出 - logger.warning('Too many awaken trial on one ship') + logger.warning('[觉醒] 单艘舰船觉醒尝试过多') return 'timeout' def awaken_exit(self, skip_first_screenshot=True): @@ -297,7 +352,7 @@ def awaken_exit(self, skip_first_screenshot=True): in: is_in_awaken out: DOCK_CHECK """ - logger.info('Awaken exit') + logger.info('[觉醒] 觉醒退出') interval = Timer(3) while 1: if skip_first_screenshot: @@ -306,10 +361,10 @@ def awaken_exit(self, skip_first_screenshot=True): self.device.screenshot() if self.ui_page_appear(page_dock): - logger.info(f'Awaken exit at {page_dock}') + logger.info(f'[觉醒] 觉醒退出在 {page_dock}') break if interval.reached() and self.is_in_awaken(): - logger.info(f'is_in_awaken -> {BACK_ARROW}') + logger.info(f'[觉醒] 在觉醒中 -> {BACK_ARROW}') self.device.click(BACK_ARROW) interval.reset() continue @@ -336,7 +391,7 @@ def awaken_run(self, use_array=False, favourite=False): in: Any out: page_dock """ - logger.hr('Awaken run', level=1) + logger.hr('觉醒运行', level=1) self.ui_ensure(page_dock) self.dock_favourite_set(enable=favourite, wait_loading=False) self.dock_sort_method_dsc_set(wait_loading=False) @@ -349,14 +404,14 @@ def awaken_run(self, use_array=False, favourite=False): while 1: # 在 page_dock 页面 if self.appear(DOCK_EMPTY, offset=(20, 20)): - logger.info('awaken_run finished, no ships to awaken') + logger.info('[觉醒] 觉醒运行完成,无舰船可觉醒') result = 'finish' break # page_dock -> SHIP_DETAIL_CHECK entered = self.dock_enter_first() if not entered: - logger.info('awaken_run finished, no ships to awaken') + logger.info('[觉醒] 觉醒运行完成,无舰船可觉醒') result = 'finish' break @@ -368,10 +423,10 @@ def awaken_run(self, use_array=False, favourite=False): # Awaken next ship continue if result == 'insufficient': - logger.info('awaken_run finished, resources exhausted') + logger.info('[觉醒] 觉醒运行完成,资源耗尽') break if result == 'timeout': - logger.info(f'awaken_run finished, result={result}') + logger.info(f'[觉醒] 觉醒运行完成, 结果={result}') break raise ScriptError(f'Unexpected awaken_ship result: {result}') @@ -393,7 +448,7 @@ def run(self): raise ScriptError(f'Unknown Awaken_LevelCap={self.config.Awaken_LevelCap}') # 重置船坞筛选器 - logger.hr('Awaken run exit', level=1) + logger.hr('觉醒运行退出', level=1) if favourite: self.dock_favourite_set(wait_loading=False) self.dock_filter_set(wait_loading=False) diff --git a/module/azur_stats/__init__.py b/module/azur_stats/__init__.py index e69de29bb..e54f9bfa0 100644 --- a/module/azur_stats/__init__.py +++ b/module/azur_stats/__init__.py @@ -0,0 +1 @@ +"""AzurStats统计模块。""" diff --git a/module/azur_stats/assets.py b/module/azur_stats/assets.py index ceb5a06a2..8715cebe3 100644 --- a/module/azur_stats/assets.py +++ b/module/azur_stats/assets.py @@ -1,3 +1,5 @@ +"""AzurStats统计模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/azur_stats/image/__init__.py b/module/azur_stats/image/__init__.py index e69de29bb..e54f9bfa0 100644 --- a/module/azur_stats/image/__init__.py +++ b/module/azur_stats/image/__init__.py @@ -0,0 +1 @@ +"""AzurStats统计模块。""" diff --git a/module/azur_stats/image/auto_search_reward.py b/module/azur_stats/image/auto_search_reward.py index f9167086b..8dc665e5c 100644 --- a/module/azur_stats/image/auto_search_reward.py +++ b/module/azur_stats/image/auto_search_reward.py @@ -1,3 +1,10 @@ +""" +自动搜索奖励截图识别。 + +从自动搜索结算画面中识别掉落物品,包括标题检测、物品网格定位 +和数量 OCR。支持多服务器的按钮模板匹配。 +""" + import typing as t import cv2 @@ -105,7 +112,7 @@ def parse_auto_search_reward(self, image, name=True, amount=True, tag=True) -> t item = self.auto_search_revise_item(item) after = str(item) if before != after: - logger.info(f'Item {before} is revised to {after}') + logger.info(f'[统计-物品] 物品 {before} 修正为 {after}') if item.amount == 0: raise ZeroAmountError(f'Invalid item amount: {item}') yield item diff --git a/module/azur_stats/image/base.py b/module/azur_stats/image/base.py index 02f0ebc4b..6cd623254 100644 --- a/module/azur_stats/image/base.py +++ b/module/azur_stats/image/base.py @@ -1,3 +1,10 @@ +""" +截图识别基类。 + +提供 ImageBase 基类用于单张截图的检测分析。包含服务器分类、 +按钮缓存和通用图像处理方法,是所有截图识别器的父类。 +""" + import typing as t import numpy as np diff --git a/module/azur_stats/image/get_items.py b/module/azur_stats/image/get_items.py index 16c8fb6d8..674f08b62 100644 --- a/module/azur_stats/image/get_items.py +++ b/module/azur_stats/image/get_items.py @@ -1,3 +1,10 @@ +""" +获得物品截图识别。 + +从战斗结算画面中识别获得的物品列表,支持单排/双排布局检测, +处理物品网格定位和数量 OCR,以及信息栏遮挡的异常情况。 +""" + import numpy as np import typing as t @@ -104,7 +111,7 @@ def parse_get_items(self, image, name=True, amount=True, tag=True) -> t.Iterator item = self.revise_item(item) after = str(item) if before != after: - logger.info(f'Item {before} is revised to {after}') + logger.info(f'[统计-物品] 物品 {before} 修正为 {after}') if item.amount == 0: raise ZeroAmountError(f'Invalid item amount: {item}') yield item diff --git a/module/azur_stats/image/opsi_reward.py b/module/azur_stats/image/opsi_reward.py index 154a8105a..e9ddad37a 100644 --- a/module/azur_stats/image/opsi_reward.py +++ b/module/azur_stats/image/opsi_reward.py @@ -1,3 +1,10 @@ +""" +大世界奖励截图识别。 + +继承 AutoSearchReward,添加大世界(Operation Siren)奖励画面的 +判断逻辑,通过按钮模板匹配区分大世界与普通奖励界面。 +""" + from module.azur_stats.image.auto_search_reward import AutoSearchReward from module.os_handler.assets import AUTO_SEARCH_REWARD diff --git a/module/azur_stats/image/opsi_zone.py b/module/azur_stats/image/opsi_zone.py index 1c1a74730..831bfd956 100644 --- a/module/azur_stats/image/opsi_zone.py +++ b/module/azur_stats/image/opsi_zone.py @@ -1,3 +1,10 @@ +""" +大世界区域截图识别。 + +从大世界地图截图中识别当前区域名称和类型,使用 OCR 读取地图名称, +并通过 ZoneManager 将显示名称映射为标准化的英文区域标识。 +""" + from dataclasses import dataclass from module.azur_stats.image.base import ImageBase diff --git a/module/azur_stats/scene/__init__.py b/module/azur_stats/scene/__init__.py index e69de29bb..e54f9bfa0 100644 --- a/module/azur_stats/scene/__init__.py +++ b/module/azur_stats/scene/__init__.py @@ -0,0 +1 @@ +"""AzurStats统计模块。""" diff --git a/module/azur_stats/scene/base.py b/module/azur_stats/scene/base.py index 05d2f1a69..a436d0619 100644 --- a/module/azur_stats/scene/base.py +++ b/module/azur_stats/scene/base.py @@ -1,3 +1,10 @@ +""" +截图场景分析基类。 + +提供 SceneBase 基类,将截图文件加载、随机 ID 生成和批量处理 +抽象为统一接口。是所有场景级截图分析器的父类。 +""" + import os import random import typing as t diff --git a/module/azur_stats/scene/operation_siren.py b/module/azur_stats/scene/operation_siren.py index a5696e1c1..97b3b42e6 100644 --- a/module/azur_stats/scene/operation_siren.py +++ b/module/azur_stats/scene/operation_siren.py @@ -1,3 +1,10 @@ +""" +大世界场景分析。 + +组合大世界奖励、物品识别和区域检测,对大世界(Operation Siren) +的截图进行完整的场景级分析,提取掉落物品和区域信息。 +""" + import typing as t from dataclasses import dataclass diff --git a/module/base/api_client.py b/module/base/api_client.py index 95a0c3137..386d4de78 100644 --- a/module/base/api_client.py +++ b/module/base/api_client.py @@ -79,7 +79,7 @@ def _request_with_fallback(cls, method: str, path: str, params: Dict[str, Any] = for i, endpoint in enumerate(endpoints): try: domain_type = "主域名" if i == 0 else "备用域名" - logger.debug(f'[Base] 尝试使用{domain_type}: {endpoint}') + logger.debug(f'[基础-API] 尝试使用{domain_type}: {endpoint}') if method == 'GET': response = requests.get( @@ -101,20 +101,20 @@ def _request_with_fallback(cls, method: str, path: str, params: Dict[str, Any] = if response.status_code in success_codes: if i > 0: - logger.info(f'[Base] ✓ 使用{domain_type}请求成功') + logger.info(f'[基础-API] 使用{domain_type}请求成功') return True, response.status_code, response.text else: - logger.warning(f'[Base] {domain_type}返回错误状态: {response.status_code}') + logger.warning(f'[基础-API] {domain_type}返回错误状态: {response.status_code}') last_error = f'HTTP {response.status_code}' except requests.exceptions.Timeout: - logger.warning(f'[Base] {domain_type if i > 0 else "主域名"}请求超时') + logger.warning(f'[基础-API] {domain_type if i > 0 else "主域名"}请求超时') last_error = 'Timeout' except requests.exceptions.RequestException as e: - logger.warning(f'[Base] {domain_type if i > 0 else "主域名"}请求失败: {e}') + logger.warning(f'[基础-API] {domain_type if i > 0 else "主域名"}请求失败: {e}') last_error = str(e) except Exception as e: - logger.warning(f'[Base] {domain_type if i > 0 else "主域名"}发生异常: {e}') + logger.warning(f'[基础-API] {domain_type if i > 0 else "主域名"}发生异常: {e}') last_error = str(e) return False, 0, last_error or 'Unknown error' @@ -143,11 +143,11 @@ def _submit_bug_log(content: str, log_type: str): ) if success: - logger.info(f'Bug log submitted: {content[:50]}...') + logger.info(f'[基础-API] Bug日志已提交: {content[:50]}...') else: - logger.warning(f'Failed to submit bug log: {response_text}') + logger.warning(f'[基础-API] 提交Bug日志失败: {response_text}') except Exception as e: - logger.warning(f'Failed to submit bug log: {e}') + logger.warning(f'[基础-API] 提交Bug日志失败: {e}') @classmethod def submit_bug_log(cls, content: str, log_type: str = 'warning', enabled: bool = True): @@ -176,13 +176,13 @@ def _submit_cl1_data(data: Dict[str, Any], timeout: int): try: # 如果没有任何战斗数据,不提交 if data.get('battle_count', 0) == 0: - logger.info('No CL1 battle data to submit') + logger.info('无CL1战斗数据可提交') return - logger.info(f'Submitting CL1 data for {data.get("month", "unknown")}...') - logger.attr('battle_count', data.get('battle_count', 0)) - logger.attr('akashi_encounters', data.get('akashi_encounters', 0)) - logger.attr('akashi_probability', f"{data.get('akashi_probability', 0):.2%}") + logger.info(f'[基础-API] 提交CL1数据 {data.get("month", "unknown")}...') + logger.attr('战斗次数', data.get('battle_count', 0)) + logger.attr('明石遭遇次数', data.get('akashi_encounters', 0)) + logger.attr('明石出现概率', f"{data.get('akashi_probability', 0):.2%}") success, status_code, response_text = ApiClient._post_with_fallback( ApiClient.CL1_DATA_PATH, @@ -191,12 +191,12 @@ def _submit_cl1_data(data: Dict[str, Any], timeout: int): ) if success: - logger.info('✓ CL1 data submitted successfully') + logger.info('[基础-API] CL1 数据提交成功') else: - logger.warning(f'✗ CL1 data submission failed: {response_text}') - + logger.warning(f'[基础-API] CL1 数据提交失败: {response_text}') + except Exception as e: - logger.exception(f'Unexpected error during CL1 data submission: {e}') + logger.exception(f'[基础-API] CL1 数据提交异常: {e}') @classmethod def submit_cl1_data(cls, data: Dict[str, Any], timeout: int = 10): diff --git a/module/base/async_executor.py b/module/base/async_executor.py index 4d21daca4..f9ffcc218 100644 --- a/module/base/async_executor.py +++ b/module/base/async_executor.py @@ -1,3 +1,9 @@ +"""异步执行器模块。 + +提供单例模式的 AsyncExecutor,内部维护后台线程运行 asyncio 事件循环。 +用于将存储、推送等阻塞操作投递到后台队列,确保主流程不被阻塞。 +""" + # -*- coding: utf-8 -*- import asyncio import threading @@ -56,9 +62,9 @@ def flush(self, timeout: float = 5.0): future = self.submit(lambda: None) future.result(timeout=timeout) except asyncio.TimeoutError: - logger.warning("AsyncExecutor flush timeout") + logger.warning("[异步执行器] 刷新超时") except Exception as e: - logger.warning(f"AsyncExecutor flush error: {e}") + logger.warning(f"[异步执行器] 刷新错误: {e}") # 全局唯一实例 diff --git a/module/base/base.py b/module/base/base.py index 6ed63e72c..e74c2caae 100644 --- a/module/base/base.py +++ b/module/base/base.py @@ -1,3 +1,9 @@ +"""模块基类定义。 + +定义所有游戏逻辑模块的最高基类 ModuleBase,整合配置管理、设备控制、 +UI 导航、任务循环控制及基本异常处理逻辑,是所有功能模块的公共祖先。 +""" + from typing import Tuple, Union from module.base.button import Button @@ -43,7 +49,7 @@ def __init__(self, config, device=None, task=None): elif isinstance(config, str): self.config = AzurLaneConfig(config, task=task) else: - logger.warning('Alas ModuleBase received an unknown config, assume it is AzurLaneConfig') + logger.warning('[基类] 收到未知的配置对象,假设是AzurLaneConfig') self.config = config if isinstance(device, Device): @@ -54,7 +60,7 @@ def __init__(self, config, device=None, task=None): self.config.override(Emulator_Serial=device) self.device = Device(config=self.config) else: - logger.warning('Alas ModuleBase received an unknown device, assume it is Device') + logger.warning('[基类] 收到未知的设备对象,假设是Device') self.device = device self.interval_timer = {} @@ -89,7 +95,7 @@ def worker(self): ... self.dungeon_update_stamina(image) >>> ModuleBase.worker.submit(func, self.device.image) """ - logger.hr('Creating worker') + logger.hr('创建后台线程池') from concurrent.futures import ThreadPoolExecutor pool = ThreadPoolExecutor(1) return pool @@ -319,7 +325,7 @@ def wait_until_stable(self, button, timer=Timer(0.3, count=1), timeout=Timer(5, button._match_init = True if timeout.reached(): - logger.warning(f'wait_until_stable({button}) timeout') + logger.warning(f'[基类] wait_until_stable({button}) 超时') break def image_crop(self, button, copy=True): @@ -459,4 +465,4 @@ def set_server(self, server): package = to_package(server) self.device.package = package set_server(server) - logger.attr('Server', self.config.SERVER) + logger.attr('服务器', self.config.SERVER) diff --git a/module/base/button.py b/module/base/button.py index 3e423c474..fdacaae41 100644 --- a/module/base/button.py +++ b/module/base/button.py @@ -1,3 +1,9 @@ +"""按钮与网格组件模块。 + +定义视觉交互系统的核心基类 Button 及 ButtonGrid,是所有 UI 交互的基本单位。 +包含坐标偏移、颜色/模板识别逻辑以及模拟点击的具体实现方案。 +""" + # 此文件定义了 Alas 视觉交互系统的核心基类:Button(按钮)及相关网格。 # 它是所有 UI 交互的基本单位,包含了坐标偏移、颜色/模板识别逻辑以及模拟点击的具体实现方案。 import typing as t diff --git a/module/base/decorator.py b/module/base/decorator.py index 0b821f7e9..14cbd3d24 100644 --- a/module/base/decorator.py +++ b/module/base/decorator.py @@ -1,3 +1,9 @@ +"""装饰器工具模块。 + +提供基于配置的方法分发装饰器 Config.when(),以及 cached_property、 +timer、function_drop、run_once 等常用装饰器,用于控制方法的执行行为。 +""" + import random import re from functools import wraps @@ -68,7 +74,7 @@ def wrapper(self, *args, **kwargs): return record['func'](self, *args, **kwargs) - logger.warning(f'No option fits for {name}, using the last define func.') + logger.warning(f'[装饰器] 没有选项适合 {name},使用最后定义的函数') return func(self, *args, **kwargs) return wrapper @@ -168,7 +174,7 @@ def wrapper(*args, **kwargs): arguments.pop(0) arguments += [f'{k}={v}' for k, v in kwargs.items()] arguments = ', '.join(arguments) - logger.info(f'Dropped: {cls}{func.__name__}({arguments})') + logger.info(f'[装饰器] 已丢弃: {cls}{func.__name__}({arguments})') return default return wrapper diff --git a/module/base/device_id.py b/module/base/device_id.py index 06cd91186..47a1d9647 100644 --- a/module/base/device_id.py +++ b/module/base/device_id.py @@ -1,5 +1,5 @@ """ -Device ID 管理模块 +设备ID 管理模块 """ import hashlib import json @@ -127,13 +127,13 @@ def _init_device_id() -> str: stored_id = old_data.get('device_id') if stored_id and stored_id != device_id: _old_device_id = stored_id - logger.info(f'Device ID change detected for migration! Old: {stored_id[:8]}, New: {device_id[:8]}') + logger.info(f'设备ID change detected for migration! Old: {stored_id[:8]}, New: {device_id[:8]}') except Exception: pass # 立即覆写新 ID _overwrite_device_id(device_id, device_id_file) - logger.info(f'Device ID initialized: {device_id[:8]}...') + logger.info(f'设备ID initialized: {device_id[:8]}...') _start_refresh_timer(device_id, device_id_file) @@ -152,7 +152,7 @@ def _overwrite_device_id(device_id: str, file_path: Path): with file_path.open('w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: - logger.warning(f'Failed to overwrite device ID file: {e}') + logger.warning(f'[设备-ID] 覆盖设备ID文件失败: {e}') def _refresh_callback(device_id: str, file_path: Path): diff --git a/module/base/filter.py b/module/base/filter.py index e72904ef3..aa93a4b5c 100644 --- a/module/base/filter.py +++ b/module/base/filter.py @@ -1,3 +1,9 @@ +"""正则过滤系统模块。 + +提供基于正则表达式的 Filter 类,用于解析和匹配游戏物品(舰船、装备等)的过滤规则。 +支持预设字符串和 ">" 分隔的优先级排序语法。 +""" + from functools import reduce import re @@ -131,7 +137,7 @@ def parse_filter(self, string): if result and len(string) and result.span()[1]: return [result.group(index + 1) for index, attr in enumerate(self.attr)] else: - logger.warning(f'Invalid filter: "{string}". This selector does not match the regex, nor a preset.') + logger.warning(f'[过滤器] 无效的过滤器: "{string}"。此选择器不匹配正则表达式,也不是预设。') # 无效的过滤条件将被忽略 # 返回不可能匹配的值以确保被跳过 return ['1nVa1d'] + [None] * (len(self.attr) - 1) diff --git a/module/base/mask.py b/module/base/mask.py index 870e70f1b..13ae1eaba 100644 --- a/module/base/mask.py +++ b/module/base/mask.py @@ -1,3 +1,9 @@ +"""遮罩模板模块。 + +定义 Mask 类,继承自 Template,扩展用于灰度遮罩图像的匹配。 +支持自动将 RGB 图像转换为灰度通道,用于游戏 UI 的区域遮罩检测。 +""" + import cv2 import numpy as np diff --git a/module/base/resource.py b/module/base/resource.py index 853323c3e..1195069a0 100644 --- a/module/base/resource.py +++ b/module/base/resource.py @@ -1,3 +1,16 @@ +"""资源管理模块。 + +管理 Button 和 Template 实例的注册、缓存释放和内存优化。 +在任务切换时释放不再需要的资源(OCR 模型、模板图像、地图检测缓存), +以控制长时间运行时的内存占用。 + +典型内存占用: + - 每个 OCR 模型约 20MB + - UI 资源(约 80 个 Button)约 3MB + - 模板图像每个约 6MB + - 地图检测缓存图像不定 +""" + import gc import re @@ -6,6 +19,15 @@ def get_assets_from_file(file, regex): + """从 Python 源文件中通过正则表达式提取资源常量名称。 + + Args: + file (str): 源文件路径。 + regex (re.Pattern): 编译后的正则表达式,需包含一个捕获组。 + + Returns: + set[str]: 匹配到的资源常量名称集合。 + """ assets = set() with open(file, 'r', encoding='utf-8') as f: for row in f.readlines(): @@ -16,6 +38,14 @@ def get_assets_from_file(file, regex): class PreservedAssets: + """收集需要在任务切换时保留的 UI 资源。 + + 这些资源用于页面检测和导航,释放后会导致无法正确识别当前页面。 + + Attributes: + ui (set[str]): 需要保留的 UI 资源名称集合,包括 UI 导航按钮和弹窗处理按钮。 + """ + @cached_property def ui(self): assets = set() @@ -36,24 +66,55 @@ def ui(self): return assets +# 全局实例,用于判断哪些资源需要保留 _preserved_assets = PreservedAssets() class Resource: + """所有 Button 和 Template 资源的基类。 + + 提供资源实例的全局注册机制和缓存释放功能。 + 所有 Button 和 Template 对象在模块加载时自动注册到 `instances` 字典中, + 任务切换时通过 `resource_release()` 批量释放已加载的图像缓存。 + + Attributes: + instances (dict[str, Resource]): 全局资源实例注册表, + 键为资源标识符(通常为文件路径或资源名称),值为 Resource 实例。 + cached (list[str]): 需要释放缓存的属性名称列表, + 子类应在创建缓存属性时维护此列表。 + """ # 类属性,记录所有按钮和模板实例 instances = {} # 实例属性,记录实例的缓存属性名称列表 cached = [] def resource_add(self, key): + """将当前实例注册到全局资源表。 + + Args: + key (str): 资源的唯一标识符。 + """ Resource.instances[key] = self def resource_release(self): + """释放当前实例的所有缓存属性。 + + 调用 `del_cached_property` 删除已缓存的属性值, + 使下次访问时重新计算或重新加载图像。 + """ for cache in self.cached: del_cached_property(self, cache) @classmethod def is_loaded(cls, obj): + """检查资源对象是否已加载图像数据。 + + Args: + obj: Button 或 Template 对象。 + + Returns: + bool: 如果图像数据已加载则返回 True。 + """ if hasattr(obj, '_image') and obj._image is None: return False elif hasattr(obj, 'image') and obj.image is None: @@ -62,8 +123,12 @@ def is_loaded(cls, obj): @classmethod def resource_show(cls): + """打印所有未加载的资源信息,用于调试。 + + 输出当前注册表中尚未加载图像数据的资源列表。 + """ from module.logger import logger - logger.hr('Show resource') + logger.hr('显示资源') for key, obj in cls.instances.items(): if cls.is_loaded(obj): continue @@ -71,14 +136,24 @@ def resource_show(cls): @staticmethod def parse_property(data, s=None): - """ - 解析 Button 或 Template 对象的属性值。 + """解析 Button 或 Template 对象的属性值。 - 用于解析 `area`、`color`、`button` 等属性,支持按服务器区分的字典或直接字符串。 + 支持按服务器区分的字典格式和直接值格式。 + 当属性值为字典时,根据当前服务器选择对应的值。 Args: - data: 属性值,字典或字符串。 - s: 服务器标识,如 'cn'、'en'、'jp'、'tw'。为 None 时使用全局 `server.server`。 + data: 属性值。可以是字典(按服务器区分)或直接值。 + s (str | None): 服务器标识,如 'cn'、'en'、'jp'、'tw'。 + 为 None 时使用全局 `server.server`。 + + Returns: + 解析后的属性值。 + + Example: + >>> Resource.parse_property({'cn': (100, 200), 'en': (110, 210)}, s='cn') + (100, 200) + >>> Resource.parse_property((100, 200)) + (100, 200) """ if s is None: s = server.server @@ -89,9 +164,21 @@ def parse_property(data, s=None): def release_resources(next_task=''): - # 释放所有 OCR 模型 - # 通常会加载 2 个模型,每个约占 20MB - # 释放后可节省 20-40MB 内存 + """释放不再需要的资源以优化内存占用。 + + 在任务调度的空闲期调用,释放三类资源: + 1. OCR 模型(每个约 20MB) + 2. Button/Template 图像缓存(UI 资源约 3MB,模板图像每个约 6MB) + 3. 地图检测缓存图像 + + 释放策略根据下一个任务动态调整: + - 大世界/委托任务即将执行时,保留 OCR 模型 + - 有后续任务时,保留 azur_lane 模型和 UI 导航资源 + - 空闲时释放所有资源 + + Args: + next_task (str): 下一个任务名称。空字符串表示空闲状态。 + """ released_ocr_models = 0 from module.webui.setting import State if State.deploy_config.UseOcrServer: diff --git a/module/base/retry.py b/module/base/retry.py index abd062c07..be1ebd540 100644 --- a/module/base/retry.py +++ b/module/base/retry.py @@ -1,3 +1,9 @@ +"""重试装饰器模块。 + +从 retry 库复制并修改,提供带退避(backoff)、抖动(jitter)和可配置异常处理的 +重试装饰器,用于自动重试失败的操作。 +""" + import functools import random import time @@ -64,7 +70,7 @@ def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, if logger is not None: # 与原版不同,显示异常详情 logger.exception(e) - logger.warning(f'{type(e).__name__}({e}), retrying in {_delay} seconds...') + logger.warning(f'{type(e).__name__}({e}), 重试,等待 {_delay} 秒...') time.sleep(_delay) _delay *= backoff diff --git a/module/base/template.py b/module/base/template.py index 1b348e5cf..ed5d49370 100644 --- a/module/base/template.py +++ b/module/base/template.py @@ -1,3 +1,9 @@ +"""模板匹配模块。 + +定义 Template 类,用于对截图进行模板匹配以识别游戏 UI 元素。 +支持服务器特定资源路径、GIF 动画模板和二值化匹配等高级功能。 +""" + import os import imageio diff --git a/module/base/timer.py b/module/base/timer.py index 930d07d16..4cf5628b0 100644 --- a/module/base/timer.py +++ b/module/base/timer.py @@ -1,3 +1,9 @@ +"""计时器与时间工具模块。 + +提供双重计时器 Timer 类(用于时间计数和访问计数)、调试用 timer 装饰器, +以及 future_time 等时间字符串解析工具函数。 +""" + from time import monotonic as time, sleep from datetime import timedelta from functools import wraps diff --git a/module/base/utils.py b/module/base/utils.py index baaeaf75d..2c653aed8 100644 --- a/module/base/utils.py +++ b/module/base/utils.py @@ -1,3 +1,9 @@ +"""基础工具函数模块。 + +提供图像处理(裁剪、颜色比较、模板匹配阈值调整)、随机坐标生成、 +图像加载与服务器回退、字母提取等底层工具函数。 +""" + import random import re diff --git a/module/campaign/ambush_1_1.py b/module/campaign/ambush_1_1.py index c98d09a05..98ef9f37f 100644 --- a/module/campaign/ambush_1_1.py +++ b/module/campaign/ambush_1_1.py @@ -1,3 +1,7 @@ +"""1-1 伏击刷关模块,用于低耗练级和钻石 farming。 +继承 GemsFarming 的刷关逻辑,重写旗舰更换策略以支持 +编队中主舰队三个槽位的自动填充与更换。""" + from module.campaign.gems_farming import GemsFarming from module.logger import logger from module.exception import CampaignEnd @@ -30,10 +34,10 @@ def flagship_change_execute(self): ship = self.get_common_rarity_cv() if ship: self.flagship_change_with_emotion(ship) - logger.info(f'Change flagship {button.name} success') + logger.info(f'[战役-伏击] 更换旗舰 {button.name} 成功') success = True else: - logger.info(f'Change flagship {button.name} failed, no CV in common rarity.') + logger.info(f'[战役-伏击] 更换旗舰 {button.name} 失败,无通用稀有度航母') if self.config.SERVER in ['cn']: max_level = 100 else: @@ -68,10 +72,10 @@ def vanguard_change_execute(self): ship = self.get_common_rarity_dd() if ship: self.vanguard_change_with_emotion(ship) - logger.info('Change vanguard ship success') + logger.info('更换前排舰船成功') return True else: - logger.info('Change vanguard ship failed, no DD in common rarity.') + logger.info('更换前排舰船失败,无通用稀有度驱逐舰。') ship = self.get_common_rarity_dd(emotion=0) if ship and self.hard_mode: self.vanguard_change_with_emotion(ship) @@ -95,10 +99,10 @@ def get_common_rarity_dd(self, emotion=16): # User explicitly requested 28 as default for 1-1 # If it's still at absolute defaults (1, 125), we force it to 1-28 if min_level <= 1 and max_level >= 125: - logger.info('Vanguard level limit is at default (1-125), forcing to 1-28 for 1-1 Ambush') + logger.info('[战役-伏击] 前排等级限制为默认值(1-125),强制改为1-28') max_level = 28 - logger.info(f'Finding vanguard with level: {min_level} ~ {max_level}') + logger.info(f'查找等级前排: {min_level} ~ {max_level}') # Implementation similar to GemsFarming but without the 100-level fallback from module.retire.scanner import ShipScanner @@ -151,7 +155,7 @@ def get_common_rarity_dd(self, emotion=16): if candidates: return candidates - logger.info('No specific DD was found, try reversed order.') + logger.info('未找到指定驱逐舰,尝试反向顺序。') self.dock_sort_method_dsc_set(False) candidates = self.find_all_vanguard_candidates(scanner, common_ship) if not candidates and self.config.GemsFarming_CommonDD == 'custom': @@ -180,7 +184,7 @@ def run(self, name='campaign_1_1_f', folder='campaign_main', mode='normal', tota Forces auto-search and clear mode off, then uses GemsFarming's ship switching logic before executing the map script. """ - logger.hr('Ambush 1-1 Runner', level=1) + logger.hr('1-1伏击运行器', level=1) # Enforce manual play and disable clear mode options self.config.override(Campaign_UseClearMode=False, Campaign_UseAutoSearch=False) @@ -212,9 +216,9 @@ def run(self, name='campaign_1_1_f', folder='campaign_main', mode='normal', tota # So we simply ensure UI, do configs, handle ships, then call campaign.run() and handle End exceptions. logger.hr(name, level=1) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'[战役-伏击] 剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'[战役-伏击] 计数: {self.run_count}') self.device.stuck_record_clear() self.device.click_record_clear() @@ -223,7 +227,7 @@ def run(self, name='campaign_1_1_f', folder='campaign_main', mode='normal', tota self.campaign.device.image = self.device.image if self.campaign.is_in_map(): - logger.info('Already in map, retreating.') + logger.info('[战役-伏击] 已在地图中,撤退中') try: self.campaign.withdraw() except CampaignEnd: @@ -270,7 +274,7 @@ def run(self, name='campaign_1_1_f', folder='campaign_main', mode='normal', tota self.set_emotion(emotion) if is_limit and self.config.StopCondition_RunCount <= 0: - logger.hr('Triggered stop condition: Run count') + logger.hr('[战役-伏击] 触发停止条件: 运行次数') self.config.StopCondition_RunCount = 0 self.config.Scheduler_Enable = False break diff --git a/module/campaign/assets.py b/module/campaign/assets.py index bdc6742c2..967e7d6b9 100644 --- a/module/campaign/assets.py +++ b/module/campaign/assets.py @@ -1,3 +1,5 @@ +"""战役模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/campaign/campaign_base.py b/module/campaign/campaign_base.py index 85fc4281d..be3dfaae0 100644 --- a/module/campaign/campaign_base.py +++ b/module/campaign/campaign_base.py @@ -1,3 +1,16 @@ +""" +战役执行基类模块。 + +提供战役任务的核心执行逻辑,包括: +- 战斗函数分发(根据地图数据选择不同的战斗策略) +- 战役全流程编排(进入地图、初始化、循环执行战斗、异常处理) +- 自动搜索模式支持 + +本模块是所有战役任务(主线、活动、作战档案等)的执行基础, +组合了 CampaignUI(UI 导航)、Map(地图操作)和 AutoSearchCombat(自动搜索战斗) +的能力。 +""" + from module.base.decorator import Config, cached_property from module.campaign.campaign_ui import CampaignUI from module.combat.auto_search_combat import AutoSearchCombat @@ -8,26 +21,63 @@ class CampaignBase(CampaignUI, Map, AutoSearchCombat): + """战役执行基类,组合 UI 导航、地图操作和自动搜索战斗能力。 + + 负责战役任务的完整执行流程:从进入地图到循环执行每一场战斗, + 直到战役结束或触发异常。通过 `@Config.when` 装饰器实现多种战斗策略的 + 条件分发,支持普通模式、全清模式和数据不足模式。 + + 战斗函数的查找机制:根据当前 battle_count 动态查找对应的战斗函数 + (如 battle_0、battle_1 等),找不到则回退到 battle_default。 + + Attributes: + FUNCTION_NAME_BASE (str): 战斗函数名称前缀,默认为 'battle_'。 + MAP (CampaignMap): 当前战役的地图数据对象,包含网格布局、敌人位置、 + 出生点等信息。由子类的地图文件定义。 + """ FUNCTION_NAME_BASE = 'battle_' MAP: CampaignMap def battle_default(self): + """默认战斗策略:清除所有敌人。 + + 作为战斗函数查找失败时的回退策略,尝试清除地图上的敌人。 + + Returns: + bool: True 表示成功执行战斗,False 表示没有执行任何战斗。 + """ if self.clear_enemy(): return True - logger.warning('No battle executed.') + logger.warning('[战役-基础] 未执行战斗') return False def battle_boss(self): + """Boss 战斗策略:强制清除 Boss。 + + 使用蛮力方式直接清除 Boss,忽略路径优化。 + + Returns: + bool: True 表示成功执行战斗,False 表示没有执行任何战斗。 + """ if self.brute_clear_boss(): return True - logger.warning('No battle executed.') + logger.warning('[战役-基础] 未执行战斗') return False @Config.when(POOR_MAP_DATA=True, MAP_CLEAR_ALL_THIS_TIME=False) def battle_function(self): - logger.info('Using function: battle_with_poor_map_data') + """战斗函数:地图数据不足模式。 + + 当地图数据不完整时使用的战斗策略。优先攻击 Boss, + 其次清除精英敌人,最后清除普通敌人。 + 会优先处理被塞壬锁定的第二舰队和神秘格子。 + + Returns: + bool: True 表示成功执行战斗,False 表示没有执行任何战斗。 + """ + logger.info('[战役-基础] 使用函数: battle_with_poor_map_data') if self.fleet_2_break_siren_caught(): return True self.clear_all_mystery() @@ -47,7 +97,15 @@ def battle_function(self): @Config.when(MAP_CLEAR_ALL_THIS_TIME=True) def battle_function(self): - logger.info('Using function: clear_all') + """战斗函数:全清模式。 + + 清除地图上所有敌人(包括精英、普通和要塞敌人)后才攻击 Boss。 + 适用于需要全清才能达成三星或 100% 通关率的关卡。 + + Returns: + bool: True 表示成功执行战斗,False 表示没有执行任何战斗。 + """ + logger.info('[战役-基础] 使用函数: clear_all') if self.fleet_2_break_siren_caught(): return True self.clear_all_mystery() @@ -59,7 +117,7 @@ def battle_function(self): .add(self.map.select(is_siren=True)) \ .add(self.map.select(is_fortress=True)) \ .delete(self.map.select(is_boss=True)) - logger.info(f'Enemy remain: {remain}') + logger.info(f'[战役-基础] 剩余敌舰: {remain}') if remain.count > 0: if self.config.MAP_HAS_MOVABLE_NORMAL_ENEMY: if self.clear_any_enemy(sort=('cost_2',)): @@ -78,13 +136,23 @@ def battle_function(self): @Config.when(MAP_CLEAR_ALL_THIS_TIME=False, POOR_MAP_DATA=False) def battle_function(self): + """战斗函数:标准模式。 + + 根据当前 battle_count 动态查找对应的战斗函数。 + 查找顺序:battle_N -> battle_(N-1) -> ... -> battle_default。 + 允许地图文件定义特定战斗步骤的自定义策略(如 battle_0 攻击 Boss, + battle_1 清除特定敌人等)。 + + Returns: + bool: True 表示成功执行战斗,False 表示没有执行任何战斗。 + """ func = self.FUNCTION_NAME_BASE + 'default' for extra_battle in range(10): if hasattr(self, self.FUNCTION_NAME_BASE + str(self.battle_count - extra_battle)): func = self.FUNCTION_NAME_BASE + str(self.battle_count - extra_battle) break - logger.info(f'Using function: {func}') + logger.info(f'[战役-基础] 使用函数: {func}') func = self.__getattribute__(func) result = func() @@ -92,6 +160,18 @@ def battle_function(self): return result def execute_a_battle(self): + """执行单场战斗。 + + 调用 battle_function() 执行一场战斗,处理 MapEnemyMoved 异常 + (敌人移动导致的地图状态变化)。如果战斗未成功执行且启用了 + 错误处理,则撤退;否则抛出 ScriptError。 + + Returns: + bool: True 表示成功执行战斗。 + + Raises: + ScriptError: 战斗未执行且未启用错误处理时抛出。 + """ logger.hr(f'{self.FUNCTION_NAME_BASE}{self.battle_count}', level=2) prev = self.battle_count result = False @@ -107,9 +187,9 @@ def execute_a_battle(self): continue if not result: - logger.warning('ScriptError, No combat executed.') + logger.warning('[战役-基础] 脚本错误,未执行战斗') if self.config.Error_HandleError: - logger.warning('ScriptError, No combat executed, Withdrawing') + logger.warning('[战役-基础] 脚本错误,未执行战斗,撤退中') self.withdraw() else: raise ScriptError('No combat executed.') @@ -117,11 +197,27 @@ def execute_a_battle(self): return result def run(self): + """执行完整的战役流程。 + + 流程: + 1. 获取地图信息并进入地图 + 2. 初始化地图(锁定舰队、初始化地图数据) + 3. 循环执行战斗(最多 20 场),直到战役结束 + 4. 异常处理:如果战斗函数耗尽,根据配置撤退或抛出异常 + + 自动搜索模式下跳过地图初始化,直接进入自动搜索战斗循环。 + + Returns: + bool: True 表示战役正常结束。 + + Raises: + ScriptError: 战斗函数耗尽且未启用错误处理时抛出。 + """ logger.hr(self.ENTRANCE, level=2) # 进入地图 self.map_get_info() - logger.attr('Map Battle', self._map_battle) + logger.attr('地图战斗次数', self._map_battle) self.emotion.check_reduce(self._map_battle) self.ENTRANCE.area = self.ENTRANCE.button self.enter_map(self.ENTRANCE, mode=self.config.Campaign_Mode) @@ -145,19 +241,19 @@ def run(self): else: self.auto_search_execute_a_battle() except CampaignEnd: - logger.hr('Campaign end') + logger.hr('战役结束') return True # 异常处理 - logger.warning('Battle function exhausted.') + logger.warning('[战役-基础] 战斗函数已耗尽') if self.config.Error_HandleError: - logger.warning('ScriptError, Battle function exhausted, Withdrawing') + logger.warning('[战役-基础] 脚本错误,战斗函数已耗尽,撤退中') try: self.withdraw() except CampaignEnd: pass else: - raise ScriptError('Battle function exhausted.') + raise ScriptError('战斗函数已耗尽。') @cached_property @Config.when(MAP_CLEAR_ALL_THIS_TIME=False) @@ -173,9 +269,9 @@ def _map_battle(self): if 'battle' in data: return data['battle'] + 1 else: - logger.warning('No battle count in spawn_data') + logger.warning('[战役-基础] 出生点数据中无战斗计数') - logger.warning('No boss data found in spawn_data') + logger.warning('[战役-基础] 出生点数据中未找到Boss数据') return 0 @cached_property @@ -194,11 +290,16 @@ def _map_battle(self): if k != 'battle': battle_count += v else: - logger.warning('No battle count in spawn_data') + logger.warning('[战役-基础] 出生点数据中无战斗计数') return battle_count def auto_search_execute_a_battle(self): + """使用自动搜索模式执行单场战斗。 + + 通过自动搜索移动舰队并执行战斗,适用于自动搜索已开启的关卡。 + 战斗完成后自动递增 battle_count。 + """ logger.hr(f'{self.FUNCTION_NAME_BASE}{self.battle_count}', level=2) self.auto_search_moving() self.auto_search_combat(fleet_index=self.fleet_show_index, diff --git a/module/campaign/campaign_event.py b/module/campaign/campaign_event.py index fe4ca65a7..28324ec37 100644 --- a/module/campaign/campaign_event.py +++ b/module/campaign/campaign_event.py @@ -1,3 +1,22 @@ +"""战役活动管理模块。 + +管理活动战役的配置和状态,包括: +- 活动结束时的自动禁用和配置重置 +- GemsFarming 任务的关卡重置(活动结束时回退到 2-4) +- 活动推送通知 +- 活动页面的导航检测 + +支持的活动类型: +- 普通活动(Event) +- 突袭活动(Raid) +- 联动活动(Coalition) +- 作战档案(War Archives) +- 医院活动(Hospital) +- 海上护卫(MaritimeEscort) + +继承自 CampaignStatus,提供活动状态检测能力。 +""" + import re from module.campaign.campaign_status import CampaignStatus @@ -12,6 +31,10 @@ class CampaignEvent(CampaignStatus): + """战役活动管理器。 + + 处理活动的生命周期管理,包括活动检测、禁用、配置重置和通知。 + """ def _reset_gems_farming(self, tasks): """ 活动结束时将 GemsFarming 重置为 2-4。 @@ -24,7 +47,7 @@ def _reset_gems_farming(self, tasks): continue name = self.config.cross_get(keys=f'{task}.Campaign.Name', default='2-4') if not self.stage_is_main(name): - logger.info(f'Reset GemsFarming to 2-4') + logger.info(f'[活动战役] 重置钻石打捞为2-4') self.config.cross_set(keys=f'{task}.Campaign.Name', value='2-4') self.config.cross_set(keys=f'{task}.Campaign.Event', value='campaign_main') @@ -41,7 +64,7 @@ def _disable_tasks(self, tasks): if task in GEMS_FARMINGS: continue keys = f'{task}.Scheduler.Enable' - logger.info(f'Disable task `{task}`') + logger.info(f'[活动战役] 禁用任务 `{task}`') self.config.cross_set(keys=keys, value=False) keys = f'{task}.Emotion.Fleet1Onsen' self.config.cross_set(keys=keys, value=False) @@ -51,7 +74,7 @@ def _disable_tasks(self, tasks): # 重置 GemsFarming self._reset_gems_farming(tasks) - logger.info(f'Reset event time limit') + logger.info(f'[活动战役] 重置活动时间限制') self.config.cross_set(keys='EventGeneral.EventGeneral.TimeLimit', value=DEFAULT_TIME) def event_pt_limit_triggered(self): @@ -79,8 +102,8 @@ def event_pt_limit_triggered(self): pt = self.get_event_pt() if pt >= limit and limit > 0: - logger.attr('Event_PT_limit', f'{pt}/{limit}') - logger.hr(f'Reach event PT limit: {limit}') + logger.attr('活动PT限制', f'{pt}/{limit}') + logger.hr(f'达到活动PT上限: {limit}') self._disable_tasks(tasks) return True else: @@ -102,12 +125,12 @@ def coin_limit_triggered(self): coin = self.get_coin() if coin == 0: # 避免 OCR 识别错误/返回零值 - logger.warning('Coin not found') + logger.warning('[活动战役] 未找到物资') return False - logger.attr('Coin_limit', f'{coin}/{limit}') + logger.attr('物资限制', f'{coin}/{limit}') if coin >= limit: - logger.hr(f'Reach coin limit: {limit}') + logger.hr(f'达到物资上限: {limit}') self.config.task_delay(minute=(120, 240)) handle_notify( self.config.Error_OnePushConfig, @@ -137,9 +160,9 @@ def event_time_limit_triggered(self): return False now = current_time().replace(microsecond=0) - logger.attr('Event_time_limit', f'{now} -> {limit}') + logger.attr('活动时间限制', f'{now} -> {limit}') if now > limit: - logger.hr(f'Reach event time limit: {limit}') + logger.hr(f'达到活动时间限制: {limit}') self._disable_tasks(tasks) return True else: @@ -158,17 +181,17 @@ def triggered_task_balancer(self): from module.config.deep import deep_get limit = self.config.TaskBalancer_CoinLimit coin = deep_get(self.config.data, 'Dashboard.Coin.Value') - logger.attr('Coin Count', coin) + logger.attr('物资数量', coin) # 检查金币 if coin == 0: # 避免 OCR 识别错误/返回零值 - logger.warning('Coin not found') + logger.warning('[活动战役] 未找到物资') return False else: if self.is_balancer_task(): if coin < limit: - logger.hr('Reach Coin limit') + logger.hr('达到物资上限') return True else: return False @@ -179,7 +202,7 @@ def handle_task_balancer(self): if self.config.TaskBalancer_Enable and self.triggered_task_balancer(): self.config.task_delay(minute=5) next_task = self.config.TaskBalancer_TaskCall - logger.hr(f'TaskBalancer triggered, switching task to {next_task}') + logger.hr(f'任务均衡器触发,切换任务到 {next_task}') self.config.task_call(next_task) self.config.task_stop() @@ -194,22 +217,22 @@ def is_event_entrance_available(self): TaskEnd: 不可用时抛出。 """ if self.appear(CAMPAIGN_MENU_NO_EVENT, offset=(20, 20)): - logger.info('Event unavailable, disable task') + logger.info('[活动战役] 活动不可用,禁用任务') tasks = EVENTS + RAIDS + COALITIONS + GEMS_FARMINGS + HOSPITAL self._disable_tasks(tasks) self.config.task_stop() else: - logger.info('Event available') + logger.info('[活动战役] 活动可用') return True def ui_goto_event(self): # 已在 page_event,跳过活动检查。 if self.ui_get_current_page() == page_event: if self.appear(WAR_ARCHIVES_CAMPAIGN_CHECK, offset=(20, 20)): - logger.info('At war archives') + logger.info('[活动战役] 在作战档案') self.ui_goto_main() else: - logger.info('Already at page_event') + logger.info('[活动战役] 已在活动页面') return True self.ui_goto(page_campaign_menu) # 检查活动是否可用 @@ -221,10 +244,10 @@ def ui_goto_sp(self): # 已在 page_sp,跳过活动检查。 if self.ui_get_current_page() == page_sp: if self.appear(WAR_ARCHIVES_CAMPAIGN_CHECK, offset=(20, 20)): - logger.info('At war archives') + logger.info('[活动战役] 在作战档案') self.ui_goto_main() else: - logger.info('Already at page_sp') + logger.info('[活动战役] 已在SP页面') return True self.ui_goto(page_campaign_menu) # 检查活动是否可用 @@ -235,7 +258,7 @@ def ui_goto_sp(self): def ui_goto_coalition(self): # 已在 page_coalition,跳过活动检查。 if self.ui_get_current_page() == page_coalition: - logger.info('Already at page_coalition') + logger.info('[活动战役] 已在联动页面') return True else: self.ui_goto(page_campaign_menu) @@ -257,7 +280,7 @@ def disable_raid_on_event(self): tasks = RAIDS + COALITIONS + MARITIME_ESCORTS tasks = [t for t in tasks if self.config.is_task_enabled(t)] if tasks: - logger.info('New event ongoing, disable old raid event tasks') + logger.info('[活动战役] 新活动进行中,禁用旧突袭活动任务') self._disable_tasks(tasks) return True else: @@ -275,7 +298,7 @@ def disable_event_on_raid(self): gems = [t for t in GEMS_FARMINGS if self.config.is_task_enabled(t)] with self.config.multi_set(): if events: - logger.info('New raid event ongoing, disable old event tasks') + logger.info('[活动战役] 新突袭活动进行中,禁用旧活动任务') self._disable_tasks(events) if gems: self._reset_gems_farming(gems) diff --git a/module/campaign/campaign_ocr.py b/module/campaign/campaign_ocr.py index d2c10cbe7..ec2b710aa 100644 --- a/module/campaign/campaign_ocr.py +++ b/module/campaign/campaign_ocr.py @@ -1,3 +1,18 @@ +"""战役关卡 OCR 识别模块。 + +通过 OCR 和模板匹配识别战役关卡页面中的关卡入口。 +在关卡选择页面中,每个关卡入口显示为一个可点击的区域, +包含关卡名称、难度星级和通关状态。 + +功能: +- 识别当前页面上的所有关卡入口 +- 通过 OCR 读取关卡名称(如 "12-4"、"D3"、"SP3") +- 匹配关卡名称到目标关卡 +- 检测关卡页面是否已完全加载 + +继承自 ModuleBase,被 CampaignUI 使用。 +""" + import collections from module.base.base import ModuleBase @@ -12,6 +27,15 @@ class CampaignOcr(ModuleBase): + """战役关卡 OCR 识别器。 + + 识别关卡选择页面上的关卡入口,通过 OCR 读取关卡名称。 + + Attributes: + stage_entrance (dict): 已识别的关卡入口缓存。 + campaign_chapter (str): 当前章节标识。 + _stage_detect_area (tuple): 关卡入口检测的大致区域,用于加速匹配。 + """ stage_entrance = {} campaign_chapter: str = '0' # 关卡入口的大致区域,用于加速模板匹配 @@ -84,10 +108,10 @@ def _campaign_separate_name(name): return name[:-1], name[-1] elif name[0].isdigit() and name[-1].isalpha(): # 49X - logger.warning(f'Unknown stage name: {name}') + logger.warning(f'[战役-OCR] 未知的关卡名称: {name}') return '', '' - logger.warning(f'Unknown stage name: {name}') + logger.warning(f'[战役-OCR] 未知的关卡名称: {name}') return '', '' def campaign_match_multi(self, template, image, stage_image=None, name_offset=(75, 9), name_size=(60, 16), @@ -335,8 +359,8 @@ def _get_stage_name(self, image): button.name = name self.stage_entrance[name] = button - logger.attr('Chapter', self.campaign_chapter) - logger.attr('Stage', ', '.join(self.stage_entrance.keys())) + logger.attr('章节', self.campaign_chapter) + logger.attr('关卡', ', '.join(self.stage_entrance.keys())) def handle_get_chapter_additional(self): """ @@ -346,7 +370,7 @@ def handle_get_chapter_additional(self): bool: 是否进行了点击操作。 """ if self.appear(WITHDRAW, offset=(30, 30)): - logger.warning(f'get_chapter_index: WITHDRAW appears') + logger.warning(f'[战役-OCR] 获取章节索引时出现撤退按钮') raise CampaignNameError def get_chapter_index(self, skip_first_screenshot=True): diff --git a/module/campaign/campaign_status.py b/module/campaign/campaign_status.py index a75089e61..851ad6f2f 100644 --- a/module/campaign/campaign_status.py +++ b/module/campaign/campaign_status.py @@ -1,3 +1,19 @@ +"""战役状态检测模块。 + +通过 OCR 读取战役页面上的数值信息,包括: +- 物资(金币)数量 +- 石油数量 +- 活动 PT(点数) +- 石油和物资限制检测 + +这些信息用于判断是否满足停止条件(如石油耗尽、物资溢出等)。 + +PtOcr 类专门处理活动 PT 数字的 OCR 识别, +需要特殊的图像预处理(反色、背景去除等)。 + +继承自 UI,利用页面导航能力。 +""" + import datetime import re @@ -59,19 +75,19 @@ def get_event_pt(self, update=False): res = re.search(r'X(\d+)', pt) if res: pt = int(res.group(1)) - logger.attr('Event_PT', pt) + logger.attr('活动PT', pt) LogRes(self.config).Pt = pt else: # 回退:若 OCR 返回纯数字也接受(保留警告以便回溯) res2 = re.search(r'(\d+)', pt) if res2: num = int(res2.group(1)) - logger.warning(f"Invalid pt result format (missing 'X'): {pt}; fallback to digits: {num}") - logger.attr('Event_PT_fallback', num) + logger.warning(f"无效的PT结果格式 (missing 'X'): {pt}; fallback to digits: {num}") + logger.attr('活动PT_回退', num) LogRes(self.config).Pt = num pt = num else: - logger.warning(f'Invalid pt result: {pt}') + logger.warning(f'无效的PT结果: {pt}') pt = 0 if update: self.config.update() @@ -93,7 +109,7 @@ def get_coin(self, skip_first_screenshot=True, update=False): self.device.screenshot() if timeout.reached(): - logger.warning('Get coin timeout') + logger.warning('获取物资超时') break _coin = { @@ -126,7 +142,7 @@ def _get_num(self, _button, name, letter=(247, 247, 247)): # 带黑色遮罩 ocr = Digit(_button, name=name, letter=(165, 165, 165), threshold=128) else: - logger.warning(f'Unexpected OCR_OIL_CHECK color') + logger.warning('[战役-状态] 意外的OCR_OIL_CHECK颜色') ocr = Digit(_button, name=name, letter=(247, 247, 247), threshold=128) return ocr.ocr(self.device.image) @@ -147,11 +163,11 @@ def get_oil(self, skip_first_screenshot=True, update=False): self.device.screenshot() if not self.appear(OCR_OIL_CHECK, offset=(10, 2)): - logger.info('No oil icon') + logger.info('无石油图标') self.device.sleep(1) if timeout.reached(): - logger.warning('Get oil timeout') + logger.warning('获取石油超时') break _oil = { diff --git a/module/campaign/campaign_ui.py b/module/campaign/campaign_ui.py index 478d73adb..b8b52cd78 100644 --- a/module/campaign/campaign_ui.py +++ b/module/campaign/campaign_ui.py @@ -1,3 +1,16 @@ +""" +战役 UI 导航与章节管理模块。 + +负责战役界面的 UI 操作,包括: +- 章节切换(数字章节、活动章节、SP 章节) +- 战役模式切换(普通/困难/EX) +- 多版本活动 UI 适配(20241219、20260326 等不同活动 UI 布局) +- 关卡入口获取与章节导航 + +本模块通过 ModeSwitch 实现战役模式的检测与切换, +通过 CampaignOcr 实现章节索引的 OCR 识别。 +""" + from module.base.timer import Timer from module.base.utils import area_offset from module.campaign.assets import * @@ -12,9 +25,16 @@ class ModeSwitch(Switch): + """战役模式切换开关。 + + 扩展 Switch 类,在切换过程中检测 WITHDRAW 按钮出现的异常情况。 + 如果在模式切换时意外出现撤退按钮,说明进入了错误的地图状态, + 会抛出 CampaignNameError 进行异常恢复。 + """ + def handle_additional(self, main): if main.appear(WITHDRAW, offset=(30, 30)): - logger.warning(f'ModeSwitch: WITHDRAW appears') + logger.warning(f'模式切换: 出现撤退按钮') raise CampaignNameError @@ -64,6 +84,23 @@ def is_digit_chapter(chapter): class CampaignUI(MapOperation, CampaignEvent, CampaignOcr): + """战役 UI 导航与管理类。 + + 提供战役界面的所有 UI 操作能力,包括章节切换、模式切换、 + 关卡入口获取等。支持主线战役、活动战役、SP 章节和作战档案 + 等多种战役类型。 + + 通过组合 MapOperation(地图操作)、CampaignEvent(活动检测) + 和 CampaignOcr(章节 OCR 识别)实现完整的战役 UI 管理。 + + 本类是 CampaignBase 的父类之一,为战役执行提供 UI 层支持。 + + Attributes: + ENTRANCE (Button): 当前关卡的入口按钮,由 ensure_campaign_ui() 设置。 + stage_entrance (dict): 关卡名称到入口按钮的映射字典, + 由 CampaignOcr 的模板匹配生成。 + campaign_chapter (str): 当前章节标识,如 '7'、'd'、'sp'。 + """ ENTRANCE = Button(area=(), color=(), button=(), name='default_button') def campaign_ensure_chapter(self, chapter, skip_first_screenshot=True): @@ -78,7 +115,7 @@ def campaign_ensure_chapter(self, chapter, skip_first_screenshot=True): isdigit = is_digit_chapter(chapter) # 复用 ui_ensure_index 的逻辑。 - logger.hr("UI ensure index") + logger.hr("UI确保索引") retry = Timer(1, count=2) error_confirm = Timer(0.2, count=0) while 1: @@ -93,7 +130,7 @@ def campaign_ensure_chapter(self, chapter, skip_first_screenshot=True): current = self.get_chapter_index() current_isdigit = is_digit_chapter(self.campaign_chapter) - logger.attr("Index", current) + logger.attr("当前索引", current) diff = index - current if diff == 0: break @@ -139,13 +176,13 @@ def campaign_ensure_mode(self, mode='normal'): if switch_2 == 'unknown': if mode == 'ex': - logger.warning('Trying to goto EX, but no EX mode switch') + logger.warning('尝试前往EX,但无EX模式切换') elif mode == 'normal': MODE_SWITCH_1.set('hard', main=self) elif mode == 'hard': MODE_SWITCH_1.set('normal', main=self) else: - logger.warning(f'Unknown campaign mode: {mode}') + logger.warning(f'未知的战役模式: {mode}') else: if mode == 'ex': MODE_SWITCH_2.set('hard', main=self) @@ -156,7 +193,7 @@ def campaign_ensure_mode(self, mode='normal'): MODE_SWITCH_2.set('ex', main=self) MODE_SWITCH_1.set('normal', main=self) else: - logger.warning(f'Unknown campaign mode: {mode}') + logger.warning(f'未知的战役模式: {mode}') def campaign_ensure_mode_20241219(self, mode='combat'): """ @@ -170,7 +207,7 @@ def campaign_ensure_mode_20241219(self, mode='combat'): elif mode in ['story']: MODE_SWITCH_20241219.set('story', main=self) else: - logger.warning(f'Unknown campaign mode: {mode}') + logger.warning(f'未知的战役模式: {mode}') def campaign_ensure_aside_20241219(self, chapter): """ @@ -188,7 +225,7 @@ def campaign_ensure_aside_20241219(self, chapter): elif chapter in ['ex', 'ex_ex']: ASIDE_SWITCH_20241219.set('ex', main=self) else: - logger.warning(f'Unknown campaign aside: {chapter}') + logger.warning(f'未知的战役旁白: {chapter}') def campaign_ensure_aside_20260326(self, chapter): """ @@ -202,7 +239,7 @@ def campaign_ensure_aside_20260326(self, chapter): elif chapter in ['sp', 'ex_sp']: ASIDE_SWITCH_20260326.set('sp', main=self) else: - logger.warning(f'Unknown campaign aside: {chapter}') + logger.warning(f'未知的战役旁白: {chapter}') def campaign_get_mode_names(self, name): """ @@ -258,7 +295,7 @@ def campaign_get_entrance(self, name): search_name = name if name == 'd3_3': search_name = 'd3' - logger.info(f'Stage {name} uses entrance {search_name} in UI') + logger.info(f'[战役-UI] 关卡 {name} 在UI中使用入口 {search_name}') if self.config.MAP_HAS_MODE_SWITCH: for mode_name in self.campaign_get_mode_names(search_name): @@ -266,7 +303,7 @@ def campaign_get_entrance(self, name): search_name = mode_name if search_name not in self.stage_entrance: - logger.warning(f'Stage not found: {search_name}') + logger.warning(f'关卡未找到: {search_name}') raise CampaignNameError entrance = self.stage_entrance[search_name] @@ -274,6 +311,18 @@ def campaign_get_entrance(self, name): return entrance def campaign_set_chapter_main(self, chapter, mode='normal'): + """ + 设置主线战役章节。 + + 导航到主线战役页面,切换到指定章节和模式。 + + Args: + chapter (str): 章节标识,如 '7'、'12'。 + mode (str): 'normal' 或 'hard'。 + + Returns: + bool: True 表示成功设置,False 表示不是主线数字章节。 + """ if chapter.isdigit(): self.ui_goto_campaign() self.campaign_ensure_mode('normal') @@ -289,6 +338,18 @@ def campaign_set_chapter_main(self, chapter, mode='normal'): return False def campaign_set_chapter_event(self, chapter, mode='normal'): + """ + 设置活动战役章节。 + + 导航到活动战役页面,根据章节标识自动切换模式(a/b 为普通,c/d 为困难)。 + + Args: + chapter (str): 章节标识,如 'a'、'b'、'c'、'd'、'sp' 等。 + mode (str): 'normal' 或 'hard'。 + + Returns: + bool: True 表示成功设置,False 表示不是活动章节。 + """ if chapter in ['a', 'b', 'c', 'd', 'ex_sp', 'as', 'bs', 'cs', 'ds', 't', 'ts', 'tss', 'ht', 'hts']: self.ui_goto_event() if chapter in ['a', 'b', 'as', 'bs', 't', 'ts', 'tss']: @@ -303,6 +364,18 @@ def campaign_set_chapter_event(self, chapter, mode='normal'): return False def campaign_set_chapter_sp(self, chapter, mode='normal'): + """ + 设置 SP 章节。 + + 导航到 SP 页面并切换到 SP 章节。 + + Args: + chapter (str): 章节标识,必须为 'sp'。 + mode (str): 'normal' 或 'hard'(未使用)。 + + Returns: + bool: True 表示成功设置,False 表示不是 SP 章节。 + """ if chapter == 'sp': self.ui_goto_sp() self.campaign_ensure_chapter(chapter) @@ -311,6 +384,22 @@ def campaign_set_chapter_sp(self, chapter, mode='normal'): return False def campaign_set_chapter_20241219(self, chapter, stage, mode='combat'): + """ + 设置 20241219 版本活动的章节。 + + 处理 2024 年 12 月起趋于稳定的活动 UI 布局,支持多种侧边栏配置: + - MAP_CHAPTER_SWITCH_20241219:标准四分区(part1/part2/sp/ex) + - MAP_CHAPTER_SWITCH_20241219_SP:简化的 SP 布局 + - MAP_CHAPTER_SWITCH_20241219_SPEX:带 EX 的 SP 布局 + + Args: + chapter (str): 章节标识,如 'a'、'b'、'sp'、'ex_sp' 等。 + stage (str): 关卡编号,如 '1'、'2'。 + mode (str): 'combat' 或 'story'。 + + Returns: + bool: True 表示成功设置,False 表示不适用此版本。 + """ if self.config.MAP_CHAPTER_SWITCH_20241219: if self._campaign_name_is_hard(f'{chapter}{stage}'): self.config.override(Campaign_Mode='hard') @@ -396,6 +485,19 @@ def campaign_set_chapter_20241219(self, chapter, stage, mode='combat'): return False def campaign_set_chapter_20260326(self, chapter, stage, mode='combat'): + """ + 设置 20260326 版本活动的章节。 + + 处理 2026 年 3 月版本的活动 UI 布局,侧边栏分为 part1 和 sp 两区。 + + Args: + chapter (str): 章节标识,如 't'、'ht'、'ex_sp'。 + stage (str): 关卡编号。 + mode (str): 'combat' 或 'story'。 + + Returns: + bool: True 表示成功设置,False 表示不适用此版本。 + """ if self.config.MAP_CHAPTER_SWITCH_20260326: if self._campaign_name_is_hard(f'{chapter}{stage}'): self.config.override(Campaign_Mode='hard') @@ -442,7 +544,7 @@ def campaign_set_chapter(self, name, mode='normal'): elif self.campaign_set_chapter_sp(chapter, mode): pass else: - logger.warning(f'Unknown campaign chapter: {name}') + logger.warning(f'[战役-UI] 未知的战役章节: {name}') def handle_campaign_ui_additional(self): """ diff --git a/module/campaign/gems_farming.py b/module/campaign/gems_farming.py index 46607a461..139f326df 100644 --- a/module/campaign/gems_farming.py +++ b/module/campaign/gems_farming.py @@ -1,3 +1,24 @@ +""" +钻石 farming(紧急委托刷钻石)模块。 + +实现通过反复刷低难度关卡触发紧急委托获取钻石的自动化流程。核心逻辑: +- 使用普通稀有度航母作为旗舰(低等级,退役后可重复获取) +- 可选更换先锋驱逐舰 +- 支持旗舰/先锋的装备码自动装卸 +- 情绪值监控:低情绪时自动更换舰船 +- 等级 32 限制:旗舰达到 32 级时自动更换(可关闭) +- 困难模式适配:困难模式下使用不同的舰队进入方式 + +典型使用场景:刷 2-4 关卡,旗舰升级到 32 级后更换新的 1 级航母, +通过紧急委托获取钻石。 + +依赖关系: +- CampaignRun:战役运行框架 +- FleetEquipment:装备管理 +- EquipmentCodeHandler:装备码导入导出 +- Retirement:退役与船坞管理 +""" + from module.base.decorator import cached_property from module.campaign.assets import CHAPTER_NEXT, CHAPTER_PREV from module.campaign.campaign_base import CampaignBase @@ -30,7 +51,14 @@ class GemsEmotion(Emotion): + """钻石 farming 专用情绪管理类。 + + 重写情绪检查逻辑:当检测到低情绪时抛出 CampaignEnd 异常 + 而不是等待恢复,以便触发舰船更换流程。 + Attributes: + 继承自 Emotion 的所有属性。 + """ def check_reduce(self, battle): """ 重写 emotion.check_reduce()。 @@ -48,7 +76,7 @@ def check_reduce(self, battle): recovered, delay = self._check_reduce(battle) if delay: self.config.GEMS_EMOTION_TRIGGERED = True - logger.info('Detect low emotion, pause current task') + logger.info('[钻石打捞] 检测到低情绪,暂停当前任务') raise CampaignEnd('Emotion control') def wait(self, fleet_index): @@ -56,7 +84,12 @@ def wait(self, fleet_index): class GemsCampaignOverride(CampaignBase): + """钻石 farming 专用战役覆写类。 + 覆写 CampaignBase 的战斗低情绪处理和经验结算处理: + - 低情绪时根据配置选择忽略警告或撤退换船 + - 支持多种经验结算弹窗的点击处理 + """ def handle_combat_low_emotion(self): """ 重写 info_handler.handle_combat_low_emotion()。 @@ -119,6 +152,14 @@ def handle_exp_info(self): class GemsEquipmentHandler(EquipmentCodeHandler): + """钻石 farming 装备处理器。 + + 继承 EquipmentCodeHandler,提供装备码的导入导出功能。 + 根据当前旗舰类型(航母/驱逐舰)自动识别装备码配置路径。 + + Attributes: + 继承自 EquipmentCodeHandler 的所有属性。 + """ def __init__(self, config, device=None, task=None): @@ -126,6 +167,11 @@ def __init__(self, config, device=None, task=None): @property def equipment_code_config_key(self): + """获取装备码配置的键路径。 + + Returns: + str: 配置键路径,如 'GemsFarming.GemsFarming.EquipmentCode'。 + """ command = self.config.task.command if hasattr(self.config, 'task') and self.config.task else 'GemsFarming' return f"{command}.GemsFarming.EquipmentCode" @@ -158,6 +204,17 @@ def current_ship(self, skip_first_screenshot=True): return 'DD' def clear_all_equip(self): + """导出当前旗舰的装备码并清空所有装备。 + + 通过装备码功能保存当前装备配置后卸下所有装备, + 以便后续应用到新旗舰上。 + + Returns: + bool: 是否成功清空。 + + Raises: + RequestHumanTakeover: 装备码导出失败时抛出,防止装备状态丢失。 + """ success = self.code_clear() if not success: logger.warning('[战役-紧急委托] 装备码导出失败,停止换船以避免装备状态丢失。') @@ -165,6 +222,19 @@ def clear_all_equip(self): return success def apply_equip_code(self, code=None): + """应用装备码到当前舰船。 + + 将之前导出的装备码应用到新旗舰上,恢复装备配置。 + + Args: + code (str, optional): 装备码字符串。为 None 时使用上次导出的装备码。 + + Returns: + bool: 是否成功应用。 + + Raises: + RequestHumanTakeover: 装备码应用失败时抛出。 + """ if code is None: success = self.code_apply() else: @@ -176,11 +246,40 @@ def apply_equip_code(self, code=None): class GemsFarming(CampaignRun, FleetEquipment, GemsEquipmentHandler, Retirement): + """钻石 farming 任务主类。 + + 组合战役运行、装备管理、装备码处理和退役管理的能力, + 实现完整的钻石 farming 自动化流程。 + + 核心流程: + 1. 加载战役地图并以普通稀有度航母为旗舰出击 + 2. 监控旗舰等级和情绪值 + 3. 旗舰达到 32 级或情绪过低时,自动更换新的低等级航母 + 4. 可选同时更换先锋驱逐舰 + 5. 通过装备码自动装卸旗舰/先锋装备 + + Attributes: + _initial_flagship_check_done (bool): 是否已完成初始旗舰等级检查。 + _trigger_lv32 (bool): 是否触发了等级 32 限制。 + _trigger_emotion (bool): 是否触发了情绪限制。 + hard_mode (bool): 是否处于困难模式(影响舰队进入方式)。 + page_fleet_check_button (Button): 舰队页面的检查按钮。 + fleet_detail_enter_flagship (Button): 进入旗舰详情的按钮。 + fleet_detail_enter (Button): 进入先锋详情的按钮。 + fleet_enter_flagship (Button): 从船坞进入旗舰位的按钮。 + fleet_enter (Button): 从船坞进入先锋位的按钮。 + """ _initial_flagship_check_done = False def hard_mode_override(self): + """根据当前战役模式切换舰队进入方式。 + + 困难模式下使用不同的按钮进入舰队编辑页面(通过战役准备界面), + 普通模式下直接通过 page_fleet 进入。根据舰队顺序配置选择 + 对应的旗舰/先锋进入按钮。 + """ if self.campaign.config.Campaign_Mode == 'hard': - logger.info('Is in hard mode, switch ship changing method.') + logger.info('[钻石打捞] 在困难模式,切换换船方式') self.hard_mode = True self._ship_detail_enter = self._ship_detail_enter_hard self._fleet_detail_enter = self._fleet_detail_enter_hard @@ -205,6 +304,16 @@ def hard_mode_override(self): self.fleet_enter = FLEET_ENTER def load_campaign(self, name, folder='campaign_main'): + """加载战役地图模块并注入钻石 farming 专用覆写。 + + 在父类 load_campaign() 基础上,将 Campaign 替换为继承了 + GemsCampaignOverride 的子类,注入 GemsEmotion 情绪管理。 + 根据是否更换先锋舰船设置情绪管理模式。 + + Args: + name (str): 地图文件名。 + folder (str): 地图文件夹名。 + """ super().load_campaign(name, folder) class GemsCampaign(GemsCampaignOverride, self.module.Campaign): @@ -223,42 +332,100 @@ def emotion(self) -> GemsEmotion: @property def emotion_lower_bound(self): + """情绪值下限。 + + 根据当前地图的战斗次数动态计算情绪值下限, + 确保舰船在整场战役中有足够的情绪值。 + + Returns: + int: 情绪值下限。 + """ return 4 + self.campaign._map_battle * 2 @property def change_flagship(self): + """是否需要更换旗舰舰船。 + + Returns: + bool: 配置中包含 'ship' 时返回 True。 + """ return 'ship' in self.config.GemsFarming_ChangeFlagship @property def change_flagship_equip(self): + """是否需要更换旗舰装备。 + + Returns: + bool: 配置中包含 'equip' 时返回 True。 + """ return 'equip' in self.config.GemsFarming_ChangeFlagship @property def change_vanguard(self): + """是否需要更换先锋舰船。 + + Returns: + bool: 配置中包含 'ship' 时返回 True。 + """ return 'ship' in self.config.GemsFarming_ChangeVanguard @property def change_vanguard_equip(self): + """是否需要更换先锋装备。 + + Returns: + bool: 配置中包含 'equip' 时返回 True。 + """ return 'equip' in self.config.GemsFarming_ChangeVanguard @property def fleet_to_attack(self): + """获取出击舰队编号。 + + 根据舰队顺序配置返回实际使用的舰队编号。 + fleet1_standby_fleet2_all 模式下使用第二舰队。 + + Returns: + int: 舰队编号。 + """ if self.config.Fleet_FleetOrder == 'fleet1_standby_fleet2_all': return self.config.Fleet_Fleet2 else: return self.config.Fleet_Fleet1 def _fleet_detail_enter(self, fleet): + """进入指定舰队的编辑页面(普通模式)。 + + 通过 page_fleet 导航到指定舰队。 + + Args: + fleet (int): 舰队编号。 + """ self.ui_ensure(page_fleet) self.ui_ensure_index(fleet, letter=OCR_FLEET_INDEX, next_button=FLEET_NEXT, prev_button=FLEET_PREV, skip_first_screenshot=True) def _ship_detail_enter(self, button): + """进入指定舰船的装备详情页面(普通模式)。 + + 从舰队页面进入舰队详情,再进入指定舰船的装备页面。 + + Args: + button (Button): 舰船位置的按钮。 + """ self.ui_click(FLEET_DETAIL, appear_button=page_fleet.check_button, check_button=FLEET_DETAIL_CHECK, skip_first_screenshot=True) self.equip_enter(button, long_click=False) def _fleet_detail_enter_hard(self, fleet): + """进入指定舰队的编辑页面(困难模式)。 + + 困难模式下通过战役准备界面进入舰队编辑, + 需要先导航到关卡入口并进入准备界面。 + + Args: + fleet (int): 舰队编号(困难模式下未使用,固定通过准备界面进入)。 + """ if self.appear(FLEET_PREPARATION, offset=(20, 50)): return self.campaign.ensure_campaign_ui(self.stage) @@ -276,13 +443,22 @@ def _fleet_detail_enter_hard(self, fleet): break def _ship_detail_enter_hard(self, button): + """进入指定舰船的装备详情页面(困难模式)。 + + 困难模式下直接通过装备进入按钮操作。 + + Args: + button (Button): 舰船位置的按钮。 + """ self.equip_enter(button) def _fleet_back(self): + """从装备详情返回到舰队页面(普通模式)。""" self.ui_back(FLEET_DETAIL_CHECK) self.ui_back(FLEET_CHECK) def _fleet_back_hard(self): + """从装备详情返回到准备页面(困难模式)。""" self.ui_back(self.page_fleet_check_button) def flagship_change(self): @@ -293,20 +469,20 @@ def flagship_change(self): bool: 是否成功更换旗舰。 """ - logger.hr('Change flagship', level=1) - logger.attr('ChangeFlagship', self.config.GemsFarming_ChangeFlagship) + logger.hr('更换旗舰', level=1) + logger.attr('更换旗舰', self.config.GemsFarming_ChangeFlagship) self._fleet_detail_enter(self.fleet_to_attack) if self.change_flagship_equip: - logger.hr('Unmount flagship equipments', level=2) + logger.hr('卸下旗舰装备', level=2) self._ship_detail_enter(self.fleet_detail_enter_flagship) self.clear_all_equip() self._fleet_back() - logger.hr('Change flagship', level=2) + logger.hr('更换旗舰', level=2) success = self.flagship_change_execute() if self.change_flagship_equip: - logger.hr('Mount flagship equipments', level=2) + logger.hr('装备旗舰装备', level=2) self._ship_detail_enter(self.fleet_detail_enter_flagship) self.apply_equip_code() self._fleet_back() @@ -320,20 +496,20 @@ def vanguard_change(self): Returns: bool: 是否成功更换先锋。 """ - logger.hr('Change vanguard', level=1) - logger.attr('ChangeVanguard', self.config.GemsFarming_ChangeVanguard) + logger.hr('更换前排', level=1) + logger.attr('更换前排', self.config.GemsFarming_ChangeVanguard) self._fleet_detail_enter(self.fleet_to_attack) if self.change_vanguard_equip: - logger.hr('Unmount vanguard equipments', level=2) + logger.hr('卸下前排装备', level=2) self._ship_detail_enter(self.fleet_detail_enter) self.clear_all_equip() self._fleet_back() - logger.hr('Change vanguard', level=2) + logger.hr('更换前排', level=2) success = self.vanguard_change_execute() if self.change_vanguard_equip: - logger.hr('Mount vanguard equipments', level=2) + logger.hr('装备前排装备', level=2) self._ship_detail_enter(self.fleet_detail_enter) self.apply_equip_code() self._fleet_back() @@ -342,11 +518,17 @@ def vanguard_change(self): return success def _dock_reset(self): + """重置船坞筛选和排序状态。""" self.dock_favourite_set(False, wait_loading=False) self.dock_sort_method_dsc_set(wait_loading=False) self.dock_filter_set() def _ship_change_confirm(self, button): + """选择舰船并确认更换。 + + Args: + button (Button): 要选择的舰船按钮。 + """ self.dock_select_one(button) self._dock_reset() self.dock_select_confirm(check_button=self.page_fleet_check_button) @@ -413,7 +595,7 @@ def get_common_rarity_cv(self, lv=31, emotion=16): # 恢复排序方式,因为已更改但未找到结果 self.dock_sort_method_dsc_set(False) - logger.info('[战役-紧急委托] UseEmotionFirst 未找到候选舰船,回退到原始选择方法。') + logger.info('[战役-紧急委托] UseEmotionFirst 未找到候选舰船,回退到原始选择方法') scanner = ShipScanner( level=(min_level, max_level), emotion=(emotion_lower_bound, 150), fleet=fleet, status='free') @@ -482,7 +664,7 @@ def get_common_rarity_dd(self, emotion=16): elif self.config.GemsFarming_CommonDD in ['aulick_or_foote', 'cassin_or_downes']: faction = 'eagle' else: - logger.error(f'Invalid CommonDD setting: {self.config.GemsFarming_CommonDD}') + logger.error(f'[钻石打捞] 无效的通用驱逐舰设置: {self.config.GemsFarming_CommonDD}') raise ScriptError('Invalid GemsFarming_CommonDD') favourite = self.config.GemsFarming_CommonDD == 'favourite' self.dock_favourite_set(favourite, wait_loading=False) @@ -575,6 +757,15 @@ def get_common_rarity_dd(self, emotion=16): return candidates def match_ship_to_template(self, ship, template): + """检查舰船图标是否匹配给定模板。 + + Args: + ship (Ship): 舰船对象。 + template: 模板对象或模板列表。 + + Returns: + bool: 是否匹配。 + """ if isinstance(template, list): return any(item.match(self.image_crop(ship.button, copy=False), similarity=SIM_VALUE) for item in template) else: @@ -624,7 +815,7 @@ def find_custom_candidates(self, scanner, ship_type='cv'): ship_type (str): 'cv' 或 'dd'。 """ if ship_type.lower() not in ['cv', 'dd']: - logger.warning(f'Invalid ship_type: {ship_type}') + logger.warning(f'[钻石打捞] 无效的舰船类型: {ship_type}') return [] ship_type = ship_type.upper() @@ -646,7 +837,7 @@ def find_custom_candidates(self, scanner, ship_type='cv'): if find_first: find_first = False if candidates: - logger.info(f'Find Common {ship_type} {name}.') + logger.info(f'[钻石打捞] 找到通用 {ship_type} {name}') return candidates common_ship_candidates[name] = candidates @@ -659,10 +850,10 @@ def find_custom_candidates(self, scanner, ship_type='cv'): candidates = self.find_candidates(template, scanner) if candidates: - logger.info(f'Find Common DD {name}.') + logger.info(f'[钻石打捞] 找到通用驱逐舰 {name}') return candidates elif common_ship_candidates[name]: - logger.info(f'Find Common DD {name}.') + logger.info(f'[钻石打捞] 找到通用驱逐舰 {name}') self.dock_sort_method_dsc_set(sort_dsc_first, wait_loading=False) return common_ship_candidates[name] @@ -700,12 +891,13 @@ def get_templates(common_dd): TEMPLATE_DOWNES_1, TEMPLATE_DOWNES_2 ] else: - logger.error(f'Invalid CommonDD setting: {common_dd}') + logger.error(f'[钻石打捞] 无效的通用驱逐舰设置: {common_dd}') raise ScriptError(f'Invalid CommonDD setting: {common_dd}') def ship_down_hard(self): - """ - 困难模式下,先让舰船离队。 + """困难模式下将舰船从舰队中移除。 + + 如果存在离队按钮则点击,否则返回准备页面。 """ if self.appear(DOCK_SHIP_DOWN): self.ui_click(DOCK_SHIP_DOWN, @@ -714,6 +906,16 @@ def ship_down_hard(self): self.ui_back(check_button=FLEET_PREPARATION) def dock_enter(self, button): + """进入船坞页面。 + + 从舰队页面点击指定位置的按钮进入船坞。 + + Args: + button (Button): 要点击的按钮。 + + Returns: + bool: True 表示成功进入,False 表示遇到游戏提示未进入。 + """ for _ in self.loop(): if self.appear(DOCK_CHECK, offset=(20, 20)): break @@ -824,17 +1026,29 @@ def vanguard_change_execute(self): _trigger_emotion = False def triggered_stop_condition(self, oil_check=True): + """检查钻石 farming 的停止条件。 + + 在父类停止条件基础上增加了: + - 等级 32 限制:旗舰达到 32 级时触发(需要更换旗舰) + - 情绪限制:情绪值过低时触发(需要更换舰船) + + Args: + oil_check (bool): 是否检查石油限制。 + + Returns: + bool: 是否触发停止条件。 + """ # 等级 32 限制 if self._trigger_lv32 or ( self.change_flagship and self.campaign.config.LV32_TRIGGERED and not self.config.GemsFarming_AllowHighFlagshipLevel): self._trigger_lv32 = True - logger.hr('TRIGGERED LV32 LIMIT') + logger.hr('[战役-打捞] 触发等级32限制') return True if self.campaign.config.GEMS_EMOTION_TRIGGERED: self._trigger_emotion = True - logger.hr('TRIGGERED EMOTION LIMIT') + logger.hr('[战役-打捞] 触发情绪限制') return True return super().triggered_stop_condition(oil_check=oil_check) @@ -934,7 +1148,7 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): success = vanguard_success and flagship_success if is_limit and self.config.StopCondition_RunCount <= 0: - logger.hr('Triggered stop condition: Run count') + logger.hr('触发停止条件: 运行次数') self.config.StopCondition_RunCount = 0 self.config.Scheduler_Enable = False break diff --git a/module/campaign/os_run.py b/module/campaign/os_run.py index 1f1764306..9bfdb0f48 100644 --- a/module/campaign/os_run.py +++ b/module/campaign/os_run.py @@ -1,3 +1,22 @@ +"""大世界战役运行模块。 + +管理大世界(Operation Siren)任务的执行流程。 + +大世界是一个独立于主线战役的开放世界系统,有自己的: +- 行动力(Action Point)机制 +- 适应性(Adaptability)系统 +- 净化装置(Purification Device) +- 港口商店和任务系统 + +此模块负责: +- 加载大世界配置和地图操作实例 +- 处理行动力溢出保护 +- 大世界任务的延迟和调度 +- 行动力不足时的任务延迟 + +继承自 OSMapOperation,提供大世界地图操作能力。 +""" + from module.config.utils import get_os_reset_remain from module.logger import logger from module.os.config import OSConfig @@ -7,6 +26,13 @@ class OSCampaignRun(OSMapOperation): + """大世界战役运行器。 + + 管理大世界任务的执行,包括行动力保护和任务调度。 + + Attributes: + PREVENT_AP_OVERFLOW_TASK (str): 防止行动力溢出的任务名称。 + """ PREVENT_AP_OVERFLOW_TASK = 'OpsiPreventActionPointOverflow' def load_campaign(self, cls=OperationSiren): @@ -18,7 +44,7 @@ def load_campaign(self, cls=OperationSiren): def delay_opsi_tasks_after_ap_limit(self, error): delay_minutes = getattr(error, 'delay_minutes', None) if delay_minutes is not None: - logger.info(f'Delay OpSi AP tasks for {delay_minutes} minutes until action points recover') + logger.info(f'[大世界-运行] 延迟大世界行动力任务 {delay_minutes} 分钟直到行动力恢复') self.config.opsi_task_delay(ap_limit=True, ap_limit_minutes=delay_minutes) def _run_opsi_task_with_ap_overflow_guard(self, runner): @@ -75,7 +101,7 @@ def opsi_meowfficer_farming(self): self.config.task_delay(server_update=True) self.config.task_call('Reward', force_call=False) else: - logger.info('Just less than 1 day to OpSi reset, delay 2.5 hours') + logger.info('[大世界-运行] 距离大世界重置不足1天,延迟2.5小时') self.config.task_delay(minute=150, server_update=True) def opsi_hazard1_leveling(self): @@ -94,7 +120,7 @@ def opsi_obscure(self): def opsi_month_boss(self): if self.config.SERVER in ['tw']: - logger.info(f'OpsiMonthBoss is not supported in {self.config.SERVER},' + logger.info(f'[大世界-运行] OpsiMonthBoss不支持服务器 {self.config.SERVER},' ' please contact server maintainers') self.config.task_delay(server_update=True) self.config.task_stop() diff --git a/module/campaign/run.py b/module/campaign/run.py index cba987417..3aaaa87b0 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -1,3 +1,16 @@ +"""战役任务运行器模块。 + +提供战役任务的完整运行框架,包括: +- 战役地图模块的动态加载与实例化 +- 多种停止条件检测(运行次数、等级、石油、金币、活动 PT 等) +- 关卡名称的标准化处理(活动名称映射、特殊 SP 名称转换、关卡循环) +- 自动搜索续战逻辑 +- 委托通知处理 + +本模块是战役任务的顶层编排器,被 alas.py 中的任务方法调用。 +负责从加载地图文件到循环执行战役的完整生命周期管理。 +""" + import copy import importlib import os @@ -16,6 +29,26 @@ class CampaignRun(CampaignEvent, ShopStatus): + """战役任务运行器。 + + 管理战役任务的完整生命周期:从动态加载战役地图模块,到循环执行战役并 + 检测各种停止条件。是所有战役类任务(Main、Event、GemsFarming 等)的 + 基础运行框架。 + + 通过 load_campaign() 动态导入 campaign/ 目录下的地图定义文件, + 实例化对应的 Campaign 对象,然后通过 run() 方法循环执行战役。 + + Attributes: + folder (str): campaign/ 下的地图文件夹名称,如 'campaign_main'。 + name (str): 地图文件名,如 '7-2'、'a1'、'sp3'。 + stage (str): 关卡标识,由 name 计算得出,用于 UI 导航。 + module: 动态加载的地图模块对象。 + config (AzurLaneConfig): 配置对象。 + campaign (CampaignBase): 当前战役的执行实例。 + run_count (int): 已完成的运行次数。 + run_limit (int): 运行次数限制。 + is_stage_loop (bool): 是否处于关卡循环模式。 + """ folder: str name: str stage: str @@ -54,10 +87,10 @@ def load_campaign(self, name, folder='campaign_main'): logger.warning(f'Map file not found: campaign.{folder}.{name}') logger.warning('[战役] 未找到地图文件。通常是用户出击未适配的地图,或者运行目录有误。') if not os.path.exists(f'./campaign/{folder}'): - logger.warning(f'Folder not exists: ./campaign/{folder}') + logger.warning(f'[战役-运行] 文件夹不存在: ./campaign/{folder}') else: files = map_files(folder) - logger.warning(f'Existing files: {files}') + logger.warning(f'[战役-运行] 现有文件: {files}') logger.critical(f'[战役] 可能的原因1: 这个活动 ({folder}) 没有 {name}') logger.critical(f'[战役] 可能的原因2: 你使用的Alas版本太旧,请检查更新,或者使用dev_tools/map_extractor.py自行制作地图文件') @@ -78,7 +111,7 @@ def triggered_stop_condition(self, oil_check=True): """ # 运行次数限制 if self.run_limit and self.config.StopCondition_RunCount <= 0: - logger.hr('Triggered stop condition: Run count') + logger.hr('触发停止条件: 运行次数') self.config.StopCondition_RunCount = 0 self.config.Scheduler_Enable = False handle_notify( @@ -89,7 +122,7 @@ def triggered_stop_condition(self, oil_check=True): return True # 等级限制 if self.config.StopCondition_ReachLevel and self.campaign.config.LV_TRIGGERED: - logger.hr(f'Triggered stop condition: Reach level {self.config.StopCondition_ReachLevel}') + logger.hr(f'触发停止条件: 达到等级 {self.config.StopCondition_ReachLevel}') self.config.Scheduler_Enable = False handle_notify( self.config.Error_OnePushConfig, @@ -104,21 +137,21 @@ def triggered_stop_condition(self, oil_check=True): # 金币限制 self.get_coin() if self.get_oil() < max(500, self.config.StopCondition_OilLimit): - logger.hr('Triggered stop condition: Oil limit') + logger.hr('触发停止条件: 石油上限') self.config.task_delay(minute=(120, 240)) return True # 金币限制 if oil_check and self.coin_limit_triggered(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') return True # 自动搜索石油限制 if self.campaign.auto_search_oil_limit_triggered: - logger.hr('Triggered stop condition: Auto search oil limit') + logger.hr('触发停止条件: 自动搜索石油上限') self.config.task_delay(minute=(120, 240)) return True # 获得新舰船 if self.config.StopCondition_GetNewShip and self.campaign.config.GET_SHIP_TRIGGERED: - logger.hr('Triggered stop condition: Get new ship') + logger.hr('触发停止条件:获得新舰船') self.config.Scheduler_Enable = False handle_notify( self.config.Error_OnePushConfig, @@ -128,17 +161,17 @@ def triggered_stop_condition(self, oil_check=True): return True # 活动限制 if oil_check and self.campaign.event_pt_limit_triggered(): - logger.hr('Triggered stop condition: Event PT limit') + logger.hr('触发停止条件: 活动PT上限') return True # 自动搜索任务均衡器 if self.config.TaskBalancer_Enable and self.campaign.auto_search_coin_limit_triggered: - logger.hr('Triggered stop condition: Auto search coin limit') + logger.hr('触发停止条件: 自动搜索物资上限') self.handle_task_balancer() return True # 任务均衡器 if oil_check and self.run_count >= 1: if self.config.TaskBalancer_Enable and self.triggered_task_balancer(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') self.handle_task_balancer() return True @@ -153,7 +186,7 @@ def _triggered_app_restart(self): """ if not self.campaign.emotion.is_ignore: if self.campaign.emotion.triggered_bug(): - logger.info('Triggered restart avoid emotion bug') + logger.info('[战役-运行] 触发重启避免情绪bug') return True return False @@ -184,10 +217,10 @@ def handle_stage_name(self, name, folder, mode='normal'): # 将 d3-3 转换为 d3_3 以使用三战撤退逻辑 if name == 'd3-3': name = 'd3_3' - logger.info('Stage name d3-3 converted to d3_3 (three-battle retreat logic)') + logger.info('[战役-运行] 关卡名d3-3转换为d3_3 (三战撤退逻辑)') # d3 保持不变,使用标准逻辑 elif name == 'd3': - logger.info('Stage name d3 using standard logic') + logger.info('[战役-运行] 关卡名d3使用标准逻辑') # GemsFarming 和 ThreeOilLowCost 自动选择活动或主线章节 if self.config.task.command in ['GemsFarming', 'ThreeOilLowCost']: if self.stage_is_main(name): @@ -299,13 +332,13 @@ def handle_stage_name(self, name, folder, mode='normal'): # TH 章节没有 map_percentage 和 3_stars if folder == 'event_20221124_cn' and name.startswith('th'): if self.config.StopCondition_MapAchievement not in ['non_stop', 'non_stop_clear_all']: - logger.info(f'When running chapter TH of event_20221124_cn, ' - f'StopCondition.MapAchievement is forced set to threat_safe') + logger.info(f'[战役-运行] 运行 event_20221124_cn 的 TH 章节时,' + f'StopCondition.MapAchievement 强制设置为 threat_safe') self.config.override(StopCondition_MapAchievement='threat_safe') if folder == 'event_20250724_cn' and name.startswith('ts'): if self.config.StopCondition_MapAchievement not in ['non_stop', 'non_stop_clear_all']: - logger.info(f'When running chapter TS of event_20250724_cn, ' - f'StopCondition.MapAchievement is forced set to threat_safe') + logger.info(f'[战役-运行] 运行 event_20250724_cn 的 TS 章节时,' + f'StopCondition.MapAchievement 强制设置为 threat_safe') self.config.override(StopCondition_MapAchievement='threat_safe') # event_20211125_cn 的 TSS 地图为限时地图 if folder == 'event_20211125_cn' and 'tss' in name: @@ -344,7 +377,7 @@ def handle_stage_name(self, name, folder, mode='normal'): name = stage.lower() self.is_stage_loop = True # 禁用连续通关 - logger.info('disable continuous clear') + logger.info('[战役-运行] 禁用连续清除') self.config.override(StopCondition_MapAchievement='non_stop') self.config.override(StopCondition_StageIncrease=False) # 如果模式为 hard 且文件存在,将 campaign_main 转换为 campaign_hard @@ -366,6 +399,14 @@ def handle_stage_name(self, name, folder, mode='normal'): return name, folder def can_use_auto_search_continue(self): + """检查是否可以继续使用自动搜索。 + + 当已在自动搜索菜单中、已完成至少一次运行、且未设置地图成就条件时, + 可以跳过 ensure_campaign_ui 直接继续自动搜索。 + + Returns: + bool: 是否可以继续自动搜索。 + """ # 自动搜索菜单中无法更新地图信息 # 如果设置了地图成就则关闭 if self.config.StopCondition_MapAchievement != 'non_stop': @@ -388,7 +429,7 @@ def handle_commission_notice(self): in: page_campaign """ if self.config.is_task_enabled('Commission') and self.campaign.commission_notice_show_at_campaign(): - logger.info('Commission notice found') + logger.info('[战役-运行] 发现委托通知') self.config.task_call('Commission') self.config.task_stop('Commission notice found') @@ -417,9 +458,9 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): # 日志 logger.hr(name, level=1) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'[战役-运行] 剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'[战役-运行] 次数: {self.run_count}') # 确保 UI 状态 self.device.stuck_record_clear() @@ -452,7 +493,7 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): from module.hard.hard import OCR_HARD_REMAIN remain = OCR_HARD_REMAIN.ocr(self.device.image) if not remain: - logger.info('Remaining number of times of hard mode campaign_main is 0, delay task to next day') + logger.info('[战役-运行] 困难模式剩余次数为0,延迟任务到明天') self.config.task_delay(server_update=True) break @@ -462,7 +503,7 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): # 更新配置 if len(self.config.modified): - logger.info('Updating config for dashboard') + logger.info('[战役-运行] 更新仪表盘配置') self.config.update() # 运行 @@ -471,7 +512,7 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): try: self.campaign.run() except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) # 撤退后关闭任务:禁用当前任务,调度器将运行后续任务 if str(e) == 'DefeatWithdraw=withdraw_stop': @@ -480,7 +521,7 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): # 更新配置 if len(self.campaign.config.modified): - logger.info('Updating config for dashboard') + logger.info('[战役-运行] 更新仪表盘配置') self.campaign.config.update() # 运行后处理 self.run_count += 1 @@ -493,13 +534,13 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): # 一次性关卡限制 if self.campaign.config.MAP_IS_ONE_TIME_STAGE: if self.run_count >= 1: - logger.hr('Triggered one-time stage limit') + logger.hr('触发一次性关卡限制') self.campaign.handle_map_stop() break # 关卡循环 if self.is_stage_loop: if self.run_count >= 1: - logger.hr('Triggered loop stage switch') + logger.hr('触发循环关卡切换') break # 调度器 if self.config.task_switched(): diff --git a/module/coalition/assets.py b/module/coalition/assets.py index 1b1036f3c..dec57a221 100644 --- a/module/coalition/assets.py +++ b/module/coalition/assets.py @@ -1,3 +1,5 @@ +"""联动模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/coalition/coalition.py b/module/coalition/coalition.py index 4839bcbee..5d538f201 100644 --- a/module/coalition/coalition.py +++ b/module/coalition/coalition.py @@ -1,3 +1,22 @@ +"""联动活动(Coalition Event)执行模块。 + +自动执行碧蓝航线的联动活动战斗。联动活动是一种特殊的限时活动, +通常分为多个难度(Easy/Normal/Hard 或 TC1/TC2/TC3),部分活动有 SP 关卡。 + +本模块的核心功能: +- 活动 PT(点数)识别:不同活动使用不同的 OCR 策略和参数 +- 燃油检查:部分活动 UI 不显示燃油图标,需跳过检查 +- 关卡标准化:兼容旧配置中的 TC-1/2/3 难度名 +- 停止条件管理:运行次数、燃油、PT、金币、任务均衡器 +- 情绪管理:单舰队模式下强制防止黄脸 + +支持的联动活动包括霜落(Frostfall)、学园、约会大作战(DAL)、 +霓虹都市、时尚、恐怖故事等。 + +配置路径: Campaign.Event (活动名称), Coalition.Mode (关卡难度), + Coalition.Fleet (舰队模式) +""" + import re from module.base.timer import Timer @@ -53,6 +72,21 @@ def after_process(self, result): class Coalition(CoalitionCombat, CampaignEvent): + """联动活动战役执行器。 + + 继承自 CoalitionCombat(联动战斗逻辑)和 CampaignEvent(活动战役基础), + 负责联动活动的完整自动化流程: + 1. 从配置读取活动名称、关卡难度和舰队模式 + 2. 标准化关卡名称(兼容旧配置格式) + 3. 循环执行战斗,每次检查停止条件 + 4. 识别活动 PT 值用于进度追踪 + 5. 处理无燃油图标的特殊活动 UI + + Attributes: + run_count: 当前已执行的战斗次数。 + run_limit: 配置的运行次数上限。 + """ + run_count: int run_limit: int @@ -80,7 +114,7 @@ def get_event_pt(self): elif event == 'coalition_20260723': ocr = Digit(HORROR_PT_OCR, name='OCR_PT', lang='cnocr', letter=(228, 230, 237), threshold=256) else: - logger.error(f'ocr object is not defined in event {event}') + logger.error(f'[联动] 活动 {event} 未定义OCR对象') raise ScriptError pt = 0 @@ -90,7 +124,7 @@ def get_event_pt(self): if pt not in [999999]: break else: - logger.warning('Wait PT timeout, assume it is') + logger.warning('等待PT超时,假设已达到') LogRes(self.config).Pt = pt self.config.update() return pt @@ -106,7 +140,7 @@ def check_oil(self): """ # 无燃油图标的联动活动跳过检查 if not self._coalition_has_oil_icon: - logger.info('Coalition event has no oil icon, skip oil check') + logger.info('联动活动无石油图标,跳过石油检查') return False limit = max(500, self.config.StopCondition_OilLimit) @@ -120,7 +154,7 @@ def check_oil(self): if self.appear(BACK_ARROW, offset=(5, 2)): break if timeout.reached(): - logger.warning('Assumes that OCR_OIL is stable') + logger.warning('假设OCR_OIL稳定') break if self.get_oil() < limit: return True @@ -157,7 +191,7 @@ def triggered_stop_condition(self, oil_check=False, pt_check=False, coin_check=F """ # 运行次数上限 if self.run_limit and self.config.StopCondition_RunCount <= 0: - logger.hr('Triggered stop condition: Run count') + logger.hr('触发停止条件: 运行次数') self.config.StopCondition_RunCount = 0 self.config.Scheduler_Enable = False return True @@ -166,22 +200,22 @@ def triggered_stop_condition(self, oil_check=False, pt_check=False, coin_check=F # 检查 ui_current 是否存在,避免属性异常 ui_is_campaign_menu = hasattr(self, 'ui_current') and self.ui_current == page_campaign_menu if (self._coalition_has_oil_icon or ui_is_campaign_menu) and self.check_oil(): - logger.hr('Triggered stop condition: Oil limit') + logger.hr('触发停止条件: 石油上限') self.config.task_delay(minute=(120, 240)) return True # 活动 PT 限制 if pt_check: if self.event_pt_limit_triggered(): - logger.hr('Triggered stop condition: Event PT limit') + logger.hr('触发停止条件: 活动PT上限') return True # 金币限制 if coin_check and self.coin_limit_triggered(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') return True # 任务均衡器 if self.run_count >= 1: if self.config.TaskBalancer_Enable and self.triggered_task_balancer(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') self.handle_task_balancer() return True @@ -208,8 +242,7 @@ def coalition_execute_once(self, event, stage, fleet): Fleet_FleetOrder='fleet1_all_fleet2_standby', ) if self.config.Coalition_Fleet == 'single' and self.config.Emotion_Fleet1Control == 'prevent_red_face': - logger.warning('AL does not allow single coalition with emotion < 30, ' - 'emotion control is forced to prevent_yellow_face') + logger.warning('[联动] 不允许单舰队联动心情低于30,强制切换为防止黄脸模式') self.config.override(Emotion_Fleet1Control='prevent_yellow_face') if stage == 'sp': # SP 关卡需要多舰队 @@ -258,7 +291,7 @@ def handle_stage_name(event, stage): 'tc3': 'hard', }.get(stage.replace('-', ''), stage) if converted != stage: - logger.warning(f'Convert legacy Coalition stage: {stage} -> {converted}') + logger.warning(f'转换旧版联动关卡: {stage} -> {converted}') stage = converted return event, stage @@ -298,9 +331,9 @@ def run(self, event='', mode='', fleet='', total=0): # 日志输出当前关卡和剩余次数 logger.hr(f'{event}_{mode}', level=2) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'计数: {self.run_count}') # 无燃油图标时,先在战役菜单检查停止条件 if not self._coalition_has_oil_icon: @@ -323,7 +356,7 @@ def run(self, event='', mode='', fleet='', total=0): try: self.coalition_execute_once(event=event, stage=mode, fleet=fleet) except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) break diff --git a/module/coalition/coalition_scuttle.py b/module/coalition/coalition_scuttle.py index faa332bfc..140fa7762 100644 --- a/module/coalition/coalition_scuttle.py +++ b/module/coalition/coalition_scuttle.py @@ -1,3 +1,7 @@ +"""联盟活动沉船刷分模块,专门处理联盟沉船战斗的结算逻辑。 +针对 D 评价沉船场景进行优化,控制心情扣减和战斗结束判定, +并处理沉船专用的结算弹窗与确认操作。""" + from module.combat.assets import ( BATTLE_STATUS_D, BATTLE_STATUS_A, BATTLE_STATUS_B, BATTLE_STATUS_S, OPTS_INFO_D, @@ -134,7 +138,7 @@ def coalition_combat(self): self.coalition_combat_re_enter() self.battle_count += 1 except CampaignEnd: - logger.info('Coalition combat end.') + logger.info('联动战斗结束。') def handle_battle_status(self, drop=None): """ @@ -169,7 +173,7 @@ def handle_battle_status(self, drop=None): if self.appear_then_click(SCUTTLE_CONFIRM, offset=(20, 20), interval=2): return True if super().handle_battle_status(drop=drop): - logger.warning("Triggered normal end") + logger.warning("触发正常结束") self.triggered_normal_end = True return True @@ -203,7 +207,7 @@ def coalition_combat_re_enter(self, skip_first_screenshot=True): from module.base.timer import Timer from module.os_ash.assets import BATTLE_STATUS - logger.info('Coalition scuttle combat re-enter') + logger.info('[联动-扫荡] 联动自沉战斗重新进入') status_clicked = False click_timer = Timer(0.3) click_last = Timer(2) @@ -301,9 +305,9 @@ def run(self, event='', mode='', fleet='', total=0): # 日志输出 logger.hr(f'{event}_{mode}', level=2) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'计数: {self.run_count}') # 无燃油图标时,先在战役菜单检查停止条件 if not self._coalition_has_oil_icon: @@ -329,7 +333,7 @@ def run(self, event='', mode='', fleet='', total=0): try: self.coalition_execute_once(event=event, stage=mode, fleet=fleet) except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) break @@ -341,7 +345,7 @@ def run(self, event='', mode='', fleet='', total=0): # SP关卡非D评价(沉船):视为已通过,延迟至服务器刷新 # D评价视为未通过,继续出击 if mode == 'sp' and self.triggered_normal_end and not self._is_shipwreck: - logger.info('SP passed with non-D rank') + logger.info('SP以非D评价通过') self.config.task_delay(server_update=True) self.config.task_stop() diff --git a/module/coalition/coalition_sp.py b/module/coalition/coalition_sp.py index 4a887e6de..82a4b3c95 100644 --- a/module/coalition/coalition_sp.py +++ b/module/coalition/coalition_sp.py @@ -1,3 +1,10 @@ +"""联动活动 SP 关卡任务模块。 + +封装联动活动 SP 模式的任务调度逻辑。调用 Coalition 基类 +以 SP 难度执行一次战斗,完成后根据执行结果设置下一次 +任务延迟或直接停止任务。 +""" + from module.coalition.coalition import Coalition from module.config.config import TaskEnd diff --git a/module/coalition/combat.py b/module/coalition/combat.py index feab3c93b..cf30e788c 100644 --- a/module/coalition/combat.py +++ b/module/coalition/combat.py @@ -1,3 +1,10 @@ +"""联动活动战斗处理模块。 + +处理联动活动中的战斗流程,包括战斗重新进入、 +战斗状态检测、战役结束判定等。继承 CoalitionUI 和 +CampaignBase,组合 UI 导航与战役战斗能力。 +""" + from module.base.timer import Timer from module.campaign.campaign_base import CampaignBase from module.coalition.assets import * @@ -16,7 +23,7 @@ def coalition_combat_re_enter(self, skip_first_screenshot=True): in: battle_status out: is_combat_executing """ - logger.info('Coalition combat re-enter') + logger.info('[联动-战斗] 联动战斗重新进入') status_clicked = False click_timer = Timer(0.3) click_last = Timer(2) @@ -80,4 +87,4 @@ def coalition_combat(self): self.coalition_combat_re_enter() self.battle_count += 1 except CampaignEnd: - logger.info('Coalition combat end.') + logger.info('[联动-战斗] 联动战斗结束') diff --git a/module/coalition/ui.py b/module/coalition/ui.py index 361b36403..d794316dd 100644 --- a/module/coalition/ui.py +++ b/module/coalition/ui.py @@ -1,3 +1,10 @@ +"""联动活动 UI 处理模块。 + +提供联动活动的界面交互功能,包括页面检测、难度模式切换、 +关卡选择确认、作战编成准备以及返回导航等。定义了 +NeoncitySwitch 用于检测带红字提示的特殊状态。 +""" + from module.base.timer import Timer from module.coalition.assets import * from module.combat.assets import BATTLE_PREPARATION @@ -55,7 +62,7 @@ def coalition_ensure_mode(self, event, mode): mode_switch.add_state('story', NEONCITY_MODE_STORY) mode_switch.add_state('battle', NEONCITY_MODE_BATTLE) elif event == 'coalition_20251120': - logger.info('Coalition event coalition_20251120 has no mode switch') + logger.info('[联动-UI] 联动活动 coalition_20251120 无模式切换') return elif event == 'coalition_20260122': mode_switch = Switch('CoalitionMode', offset=(20, 20)) @@ -63,10 +70,10 @@ def coalition_ensure_mode(self, event, mode): mode_switch.add_state('battle', FASHION_MODE_BATTLE) elif event == 'coalition_20260723': # 常规活动入口直接进入作战页面,无需切换剧情模式。 - logger.info('Mystery Record event has no mode switch') + logger.info('[联动-UI] 恐怖故事活动无模式切换') return else: - logger.error(f'MODE_SWITCH is not defined in event {event}') + logger.error(f'[联动-UI] MODE_SWITCH未定义在活动中 {event}') raise ScriptError if mode == 'story': @@ -74,7 +81,7 @@ def coalition_ensure_mode(self, event, mode): elif mode == 'battle': mode_switch.set('battle', main=self) else: - logger.warning(f'Unknown coalition campaign mode: {mode}') + logger.warning(f'未知的联动战役模式: {mode}') def coalition_set_fleet(self, event, mode): """ @@ -108,7 +115,7 @@ def coalition_set_fleet(self, event, mode): fleet_switch.add_state('single', MYSTERY_RECORD_SWITCH_SINGLE) fleet_switch.add_state('multi', MYSTERY_RECORD_SWITCH_MULTI) else: - logger.error(f'FLEET_SWITCH is not defined in event {event}') + logger.error(f'[联动-UI] FLEET_SWITCH未定义在活动中 {event}') raise ScriptError if fleet_switch.get(main=self) == mode: @@ -120,7 +127,7 @@ def coalition_set_fleet(self, event, mode): fleet_switch.set('multi', main=self) return True else: - logger.warning(f'Unknown coalition fleet mode: {mode}') + logger.warning(f'未知的联动舰队模式: {mode}') return False @staticmethod @@ -302,7 +309,7 @@ def coalition_get_fleet_preparation(event): elif event == 'coalition_20260723': return MYSTERY_RECORD_FLEET_PREPARATION else: - logger.error(f'FLEET_PREPARATION is not defined in event {event}') + logger.error(f'[联动-UI] FLEET_PREPARATION未定义在活动中 {event}') raise ScriptError def handle_fleet_preparation(self, event, stage, mode): @@ -354,7 +361,7 @@ def coalition_map_exit(self, event): in: BATTLE_PREPARATION, or coalition specific fleet_preparation out: in_coalition """ - logger.info('Coalition map exit') + logger.info('联动地图退出') fleet_preparation = self.coalition_get_fleet_preparation(event) for _ in self.loop(): if self.in_coalition(): diff --git a/module/combat/assets.py b/module/combat/assets.py index b6eb2d1e1..032a3d6cb 100644 --- a/module/combat/assets.py +++ b/module/combat/assets.py @@ -1,3 +1,5 @@ +"""战斗模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/combat/auto_search_combat.py b/module/combat/auto_search_combat.py index 8cf90d1fc..9f8a07480 100644 --- a/module/combat/auto_search_combat.py +++ b/module/combat/auto_search_combat.py @@ -1,3 +1,19 @@ +"""自动搜索战斗管理器。 + +管理通关模式(快进模式)下的自动搜索战斗流程。 + +在通关模式下,游戏会自动进行地图探索和战斗。 +此模块负责: +- 启动自动搜索(地图中的出击按钮) +- 等待自动搜索完成(检测回到关卡页面) +- 处理战斗期间的异常(退役、低情绪、撤退等) +- 检测停止条件(石油/物资限制、通关次数等) +- Boss 战后的关卡推进 + +继承自 MapOperation + Combat + CampaignStatus, +组合了地图操作、战斗系统和战役状态追踪的能力。 +""" + from module.base.timer import Timer from module.campaign.campaign_status import CampaignStatus from module.combat.assets import * @@ -10,6 +26,18 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): + """自动搜索战斗执行器。 + + 在通关模式下编排自动搜索战斗流程,处理各种战斗异常和停止条件。 + + Attributes: + _auto_search_in_stage_timer (Timer): 关卡页面检测计时器。 + _auto_search_status_confirm (bool): 自动搜索状态是否已确认。 + _withdraw (bool): 是否已执行撤退。 + _defeat_count (int): 战败次数。 + auto_search_oil_limit_triggered (bool): 石油限制是否已触发。 + auto_search_coin_limit_triggered (bool): 物资限制是否已触发。 + """ _auto_search_in_stage_timer = Timer(3, count=6) _auto_search_status_confirm = False _withdraw = False @@ -28,7 +56,7 @@ def _handle_auto_search_menu_missing(self): """ if self.is_in_stage(): if self._auto_search_in_stage_timer.reached(): - logger.info('Catch auto search menu missing') + logger.info('捕获自动搜索菜单缺失') return True else: self._auto_search_in_stage_timer.reset() @@ -80,12 +108,12 @@ def auto_search_watch_fleet(self, checked=False): if self.fleet_current_index == prev: # Same as current, only print once if not checked: - logger.info(f'Fleet: {self.fleet_show_index}, fleet_current_index: {self.fleet_current_index}') + logger.info(f'[自动搜索-舰队] 舰队: {self.fleet_show_index}, 当前舰队索引: {self.fleet_current_index}') checked = True self.lv_get(after_battle=True) else: # Fleet changed - logger.info(f'Fleet: {self.fleet_show_index}, fleet_current_index: {self.fleet_current_index}') + logger.info(f'[自动搜索-舰队] 舰队: {self.fleet_show_index}, 当前舰队索引: {self.fleet_current_index}') checked = True self.lv_get(after_battle=False) @@ -99,15 +127,15 @@ def auto_search_watch_oil(self, checked=False): if not checked: oil = self.get_oil() if oil == 0: - logger.warning('Oil not found') + logger.warning('未找到石油') else: if oil < max(500, self.config.StopCondition_OilLimit): - logger.info('Reach oil limit') + logger.info('达到石油上限') self.auto_search_oil_limit_triggered = True else: if self.auto_search_oil_limit_triggered: - logger.warning('auto_search_oil_limit_triggered but oil recovered, ' - 'probably because of wrong OCR result before') + logger.warning('[自动搜索-石油] 石油限制已触发但石油已恢复,' + '可能是因为之前的OCR结果错误') self.auto_search_oil_limit_triggered = False checked = True @@ -122,11 +150,11 @@ def auto_search_watch_coin(self, checked=False): limit = self.config.TaskBalancer_CoinLimit coin = self.get_coin() if coin == 0: - logger.warning('Coin not found') + logger.warning('未找到物资') else: if self.is_balancer_task(): if coin < limit: - logger.info('Reach coin limit') + logger.info('达到物资上限') self.auto_search_coin_limit_triggered = True else: # Enough coin @@ -156,7 +184,7 @@ def _wait_until_in_map(self, skip_first_screenshot=True): if self.is_in_map(): break if timeout.reached(): - logger.warning('Wait in_map after retirement timeout, assume it is in_map') + logger.warning('[自动搜索-地图] 等待退役后进入地图超时,假设已在地图中') break def auto_search_moving(self, skip_first_screenshot=True): @@ -165,7 +193,7 @@ def auto_search_moving(self, skip_first_screenshot=True): in: map out: is_combat_loading() """ - logger.info('Auto search moving') + logger.info('自动搜索移动中') self.device.stuck_record_clear() checked_fleet = False checked_oil = False @@ -197,7 +225,7 @@ def auto_search_moving(self, skip_first_screenshot=True): if self.is_combat_loading(): break if self.is_combat_executing(): - logger.info('is_combat_executing') + logger.info('[自动搜索-战斗] 战斗执行中') break if self.is_in_auto_search_menu() or self._handle_auto_search_menu_missing(): raise CampaignEnd @@ -213,7 +241,7 @@ def auto_search_combat_execute(self, emotion_reduce, fleet_index, battle=None, e in: is_combat_loading() out: combat status """ - logger.info('Auto search combat loading') + logger.info('自动搜索战斗加载中') self.device.stuck_record_clear() self.device.click_record_clear() self.device.screenshot_interval_set('combat') @@ -231,10 +259,10 @@ def auto_search_combat_execute(self, emotion_reduce, fleet_index, battle=None, e raise CampaignEnd pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) break - logger.info('Auto Search combat execute') + logger.info('[自动搜索-战斗] 战斗执行') self.submarine_call_reset() submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: @@ -357,7 +385,7 @@ def _handle_fleet_switch_over(self): if self.appear_then_click(SWITCH_OVER, interval=2): continue if timeout.reached(): - logger.warning('Fleet switch over timeout, withdraw instead') + logger.warning('舰队切换超时,改为撤退') self.withdraw() break self.fleet_alive_multiple = False @@ -369,7 +397,7 @@ def auto_search_combat_status(self): in: any out: is_auto_search_running() """ - logger.info('Auto Search combat status') + logger.info('[自动搜索-结算] 战斗结算') self.device.stuck_record_clear() self.device.click_record_clear() exp_info = False # This is for the white screen bug in game @@ -382,7 +410,7 @@ def auto_search_combat_status(self): self._auto_search_status_confirm = False # 战斗正常结束(非战败),重置连续战败计数 if self._defeat_count > 0: - logger.info('Battle won, reset defeat count') + logger.info('战斗胜利,重置失败计数') self._defeat_count = 0 break if self.is_in_auto_search_menu() or self._handle_auto_search_menu_missing(): @@ -421,7 +449,7 @@ def auto_search_combat_status(self): if defeat_withdraw == 'withdraw_stop': # 撤退后关闭任务:连续3次战败才关闭任务 self._defeat_count += 1 - logger.attr('Defeat_count', f'{self._defeat_count}/3') + logger.attr('战败计数', f'{self._defeat_count}/3') if self._defeat_count >= 3: # 连续3次战败,关闭任务 # withdraw()内部抛出CampaignEnd, @@ -501,4 +529,4 @@ def auto_search_combat(self, emotion_reduce=None, fleet_index=1, battle=None): self.auto_search_combat_execute(emotion_reduce=emotion_reduce, fleet_index=fleet_index, battle=battle) self.auto_search_combat_status() - logger.info('Combat end.') + logger.info('[自动搜索-战斗] 战斗结束') diff --git a/module/combat/combat.py b/module/combat/combat.py index 0775ffbbe..c11d0e2e1 100644 --- a/module/combat/combat.py +++ b/module/combat/combat.py @@ -1,3 +1,25 @@ +"""战斗系统核心处理器。 + +整合所有战斗相关的功能模块,提供完整的战斗流程管理。 + +继承自多个战斗子模块: +- Level: 等级检测 +- HPBalancer: 血量平衡管理 +- Retirement: 退役处理(船坞满时自动退役) +- SubmarineCall: 潜艇呼叫 +- CombatAuto: 自动战斗模式 +- CombatManual: 手动战斗模式 +- AutoSearchHandler: 自动搜索处理 + +战斗流程: +1. combat_appear() 检测战斗画面 +2. handle_combat_automation() 设置自动/手动模式 +3. handle_combat_low_emotion() 处理低情绪警告 +4. handle_retirement() 处理退役提示 +5. 等待战斗结束(combat_status) +6. 处理战斗结果(经验、掉落等) +""" + import numpy as np from module.base.timer import Timer @@ -20,6 +42,17 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatManual, AutoSearchHandler): + """战斗系统核心处理器。 + + 整合等级检测、血量平衡、退役、潜艇、自动/手动战斗和自动搜索等功能, + 提供从战斗开始到结束的完整流程管理。 + + 通过多重继承组合各子模块的能力,子模块各自处理战斗的特定方面。 + + Attributes: + _automation_set_timer (Timer): 自动化模式设置的防抖计时器。 + battle_status_click_interval (int): 战斗状态点击间隔。 + """ _automation_set_timer = Timer(1) battle_status_click_interval = 0 @@ -78,10 +111,10 @@ def is_combat_loading(self): similarity, button = TEMPLATE_COMBAT_LOADING.match_luma_result(image) if similarity > lower_template_match_similarity(0.85): loading = (button.area[0] + 38 - LOADING_BAR.area[0]) / (LOADING_BAR.area[2] - LOADING_BAR.area[0]) - logger.attr('Loading', f'{int(loading * 100)}%') + logger.attr('加载进度', f'{int(loading * 100)}%') return True if self.is_combat_executing(): - logger.warning('[战斗] 检测到战斗状态但未检测到加载条') + logger.warning('[战斗-加载] 检测到战斗状态但未检测到加载条') return True return False @@ -264,7 +297,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba auto: 自动战斗模式,'combat_auto' 或其他模式。 fleet_index: 舰队索引,1 或 2。 """ - logger.info('Combat preparation.') + logger.info('[战斗-准备] 战斗准备') self.device.stuck_record_clear() self.device.click_record_clear() skip_first_screenshot = True @@ -301,7 +334,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba # 检测到战斗执行中,退出准备阶段 pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) if emotion_reduce: self.emotion.reduce(fleet_index) # 如果未检测到加载画面,兜底降低截图频率 @@ -337,7 +370,7 @@ def handle_combat_automation_set(self, auto): return False if self.appear(AUTOMATION_ON): - logger.info('[Automation] ON') + logger.info('[战斗-自动化] 自动战斗开启') if not auto: self.device.click(AUTOMATION_SWITCH) self.device.sleep(1) @@ -345,7 +378,7 @@ def handle_combat_automation_set(self, auto): return True if self.appear(AUTOMATION_OFF): - logger.info('[Automation] OFF') + logger.info('[战斗-自动化] 自动战斗关闭') if auto: self.device.click(AUTOMATION_SWITCH) self.device.sleep(1) @@ -375,11 +408,11 @@ def handle_emergency_repair_use(self): if not self.appear(EMERGENCY_REPAIR_AVAILABLE): return False - logger.info('EMERGENCY_REPAIR_AVAILABLE') + logger.info('[战斗-维修] 紧急维修可用') if not len(self.hp): return False if max(self.hp[:3]) <= 0.001 or max(self.hp[3:]) <= 0.001: - logger.warning(f'Invalid HP to use emergency repair: {self.hp}') + logger.warning(f'[战斗-维修] 使用紧急维修时血量无效: {self.hp}') return False hp = np.array(self.hp) @@ -387,7 +420,7 @@ def handle_emergency_repair_use(self): if (len(hp) and np.min(hp) < self.config.HpControl_RepairUseSingleThreshold) \ or max(self.hp[:3]) < self.config.HpControl_RepairUseMultiThreshold \ or max(self.hp[3:]) < self.config.HpControl_RepairUseMultiThreshold: - logger.info('Use emergency repair') + logger.info('[战斗-维修] 使用紧急维修') self.device.click(EMERGENCY_REPAIR_AVAILABLE) self.interval_clear(EMERGENCY_REPAIR_CONFIRM) return True @@ -407,7 +440,7 @@ def combat_execute(self, auto='combat_auto', submarine='do_not_use', drop=None): submarine: 潜艇模式,可选 'do_not_use'、'hunt_only'、'every_combat'。 drop: 掉落记录对象,用于统计。 """ - logger.info('Combat execute') + logger.info('[战斗-执行] 战斗执行') self.submarine_call_reset() self.combat_auto_reset() self.combat_manual_reset() @@ -472,7 +505,7 @@ def handle_battle_status(self, drop=None): self.device.click(BATTLE_STATUS_S) return True if self.appear(BATTLE_STATUS_A, interval=self.battle_status_click_interval): - logger.warning('Battle status A') + logger.warning('[战斗-结算] 战斗评价 A') if drop: drop.handle_add(self) else: @@ -480,7 +513,7 @@ def handle_battle_status(self, drop=None): self.device.click(BATTLE_STATUS_A) return True if self.appear(BATTLE_STATUS_B, interval=self.battle_status_click_interval): - logger.warning('Battle Status B') + logger.warning('[战斗-结算] 战斗评价 B') if drop: drop.handle_add(self) else: @@ -488,7 +521,7 @@ def handle_battle_status(self, drop=None): self.device.click(BATTLE_STATUS_B) return True if self.appear(BATTLE_STATUS_C, interval=self.battle_status_click_interval): - logger.warning('Battle Status C') + logger.warning('[战斗-结算] 战斗评价 C') if drop: drop.handle_add(self) else: @@ -496,7 +529,7 @@ def handle_battle_status(self, drop=None): self.device.click(BATTLE_STATUS_C) return True if self.appear(BATTLE_STATUS_D, interval=self.battle_status_click_interval): - logger.warning('Battle Status D') + logger.warning('[战斗-结算] 战斗评价 D') if drop: drop.handle_add(self) else: @@ -586,7 +619,7 @@ def handle_get_ship(self, drop=None): """ if self.appear_then_click(GET_SHIP, interval=1): if self.appear(NEW_SHIP): - logger.info('Get a new SHIP') + logger.info('[战斗-舰船] 获得新舰船') if drop: drop.handle_add(self) self.config.GET_SHIP_TRIGGERED = True @@ -606,11 +639,11 @@ def handle_combat_mis_click(self): 是否处理了误点击。 """ if self.appear(MUNITIONS_CHECK, offset=(20, 20), interval=5): - logger.info(f'{MUNITIONS_CHECK} -> {BACK_ARROW}') + logger.info(f'[战斗-误点击] 误入军需页面 {MUNITIONS_CHECK} -> {BACK_ARROW}') self.device.click(BACK_ARROW) return True if self.appear(EXERCISE_CHECK, offset=(20, 20), interval=5): - logger.info(f'{EXERCISE_CHECK} -> {BACK_ARROW}') + logger.info(f'[战斗-误点击] 误入演习页面 {EXERCISE_CHECK} -> {BACK_ARROW}') self.device.click(BACK_ARROW) return True @@ -629,8 +662,8 @@ def combat_status(self, drop=None, expected_end=None): expected_end: 预期结束状态,可选 'with_searching'、'no_searching'、'in_stage'、'in_ui', 也可传入回调函数。 """ - logger.info('Combat status') - logger.attr('expected_end', expected_end.__name__ if callable(expected_end) else expected_end) + logger.info('[战斗-结算] 战斗结算') + logger.attr('预期结束状态', expected_end.__name__ if callable(expected_end) else expected_end) self.device.screenshot_interval_set() self.device.stuck_record_clear() self.device.click_record_clear() @@ -661,7 +694,7 @@ def combat_status(self, drop=None, expected_end=None): continue if self.handle_popup_confirm('COMBAT_STATUS'): if battle_status and not exp_info: - logger.info('Locking a new ship') + logger.info('[战斗-舰船] 锁定新舰船') self.config.GET_SHIP_TRIGGERED = True continue if not battle_status: @@ -744,4 +777,4 @@ def combat(self, balance_hp=None, emotion_reduce=None, auto_mode=None, submarine self.combat_status( drop=drop, expected_end=expected_end) - logger.info('Combat end.') + logger.info('[战斗-结束] 战斗结束') diff --git a/module/combat/combat_auto.py b/module/combat/combat_auto.py index 18147388f..64bf0c160 100644 --- a/module/combat/combat_auto.py +++ b/module/combat/combat_auto.py @@ -1,3 +1,17 @@ +"""自动战斗模式管理模块。 + +管理战斗中的自动/手动模式切换。 + +碧蓝航线的战斗支持两种模式: +- 自动模式(Auto):舰船自动移动和攻击,玩家无需操作 +- 手动模式(Manual):玩家控制舰船移动和攻击时机 + +自动模式通过战斗画面中的 Auto 按钮切换。 +不同情绪值下 Auto 按钮的位置可能不同(133/150 偏移量)。 + +继承自 ModuleBase,被 Combat 组合使用。 +""" + from module.base.base import ModuleBase from module.base.timer import Timer from module.combat.assets import COMBAT_AUTO, COMBAT_AUTO_133, COMBAT_AUTO_150, COMBAT_AUTO_SWITCH @@ -5,6 +19,17 @@ class CombatAuto(ModuleBase): + """自动战斗模式管理器。 + + 检测和切换战斗中的自动/手动模式。 + + Attributes: + auto_skip_timer (Timer): 自动跳过检测计时器。 + auto_click_interval_timer (Timer): 自动点击间隔计时器。 + auto_mode_checked (bool): 自动模式是否已检查。 + auto_mode_switched (bool): 自动模式是否已切换。 + auto_mode_click_timer (Timer): 自动模式点击计时器。 + """ auto_skip_timer = Timer(1) auto_click_interval_timer = Timer(1) auto_mode_checked = False @@ -39,7 +64,7 @@ def handle_combat_auto(self, auto): if self.auto_mode_checked: return False if self.auto_mode_click_timer.reached(): - logger.info('Combat auto check timer reached') + logger.info('[战斗-自动] 自动模式检查计时器到达') self.auto_mode_checked = True return False if not self.auto_skip_timer.reached(): diff --git a/module/combat/combat_manual.py b/module/combat/combat_manual.py index 3c52fe0e6..8f0a40062 100644 --- a/module/combat/combat_manual.py +++ b/module/combat/combat_manual.py @@ -1,8 +1,34 @@ +"""手动战斗模式管理模块。 + +管理手动战斗中的舰队移动和操作策略。 + +手动战斗模式下,需要通过摇杆控制舰队移动。 +支持的操作模式: +- stand_still_in_the_middle: 舰队停留在画面中央(适合防空关卡) +- 其他自定义移动模式 + +手动模式通常用于: +- 需要精确控制舰队位置的关卡 +- 自动模式无法通过的高难度关卡 +- 特殊战术需求(如潜艇战) + +继承自 ModuleBase,被 Combat 组合使用。 +""" + from module.base.base import ModuleBase from module.combat.assets import * class CombatManual(ModuleBase): + """手动战斗模式管理器。 + + 管理手动战斗中的舰队移动和操作。 + + Attributes: + auto_mode_checked (bool): 自动模式是否已检查。 + auto_mode_switched (bool): 是否刚从自动模式切换过来。 + manual_executed (bool): 是否已执行手动操作。 + """ auto_mode_checked = False auto_mode_switched = False manual_executed = False diff --git a/module/combat/emotion.py b/module/combat/emotion.py index 01e8641f0..cb1e73959 100644 --- a/module/combat/emotion.py +++ b/module/combat/emotion.py @@ -1,3 +1,22 @@ +"""情绪管理系统。 + +追踪和管理舰队的情绪值(心情值)。碧蓝航线中,舰船在战斗中会消耗情绪, +情绪过低会导致经验加成失效、出现负面表情等。情绪通过以下方式恢复: +- 港区休息(不在后宅):每 6 分钟恢复 20 点 +- 后宅一楼:每 6 分钟恢复 40 点 +- 后宅二楼:每 6 分钟恢复 50 点 +- 誓约加成:额外 +10 点/6分钟 +- 温泉加成:额外 +10 点/6分钟 + +情绪控制策略: +- 保持开心加成(>120):最大化经验加成 +- 防止绿脸(>40):避免负面效果 +- 防止黄脸(>30):避免严重负面效果 +- 防止红脸(>2):最低限度保护 + +游戏客户端存在已知 bug:长时间运行后情绪计算不准确,需要定期重启。 +""" + from datetime import datetime, timedelta from time import sleep @@ -10,27 +29,41 @@ from module.exception import ScriptEnd, ScriptError, RequestHumanTakeover from module.logger import logger +# 情绪控制阈值:当情绪低于此值时触发等待/延迟 DIC_LIMIT = { - 'keep_exp_bonus': 120, - 'prevent_green_face': 40, - 'prevent_yellow_face': 30, - 'prevent_red_face': 2, + 'keep_exp_bonus': 120, # 保持经验加成(心情开心) + 'prevent_green_face': 40, # 防止绿脸 + 'prevent_yellow_face': 30, # 防止黄脸 + 'prevent_red_face': 2, # 防止红脸 } +# 情绪恢复速度:每 6 分钟恢复的点数 DIC_RECOVER = { - 'not_in_dormitory': 20, - 'dormitory_floor_1': 40, - 'dormitory_floor_2': 50, + 'not_in_dormitory': 20, # 港区休息 + 'dormitory_floor_1': 40, # 后宅一楼 + 'dormitory_floor_2': 50, # 后宅二楼 } +# 情绪上限 DIC_RECOVER_MAX = { 'not_in_dormitory': 119, 'dormitory_floor_1': 150, 'dormitory_floor_2': 150, } -OATH_RECOVER = 10 -ONSEN_RECOVER = 10 +OATH_RECOVER = 10 # 誓约额外恢复速度 +ONSEN_RECOVER = 10 # 温泉额外恢复速度 class FleetEmotion: + """单个舰队的情绪追踪器。 + + 管理一个舰队的情绪值、恢复速度和控制阈值。 + 支持独立配置和公海舰队(Public Fleet)模式。 + + Attributes: + config (AzurLaneConfig): 配置对象。 + fleet (str): 舰队索引(1、2 或 'Public')。 + current (int): 当前计算的情绪值。 + """ + def __init__(self, config, fleet): """ Args: @@ -162,8 +195,8 @@ def get_recovered(self, expected_reduce=0): # 否则会导致无限任务延迟 if self.control == 'keep_exp_bonus' and expected_reduce >= 29: expected_reduce = 29 - logger.info(f'Fleet {self.fleet} expected_reduce is limited to 29 ' - f'when Emotion Control=\"Keep Happy Bonus\"') + logger.info(f'[情绪-舰队] 舰队 {self.fleet} 预期扣减限制为29 ' + f'当情绪控制="保持快乐奖励"时') emotion_needed = self.limit + expected_reduce - self.current if emotion_needed <= 0: @@ -173,6 +206,19 @@ def get_recovered(self, expected_reduce=0): return current_time() + timedelta(seconds=seconds_needed) class Emotion: + """情绪管理主类。 + + 编排两个舰队(和可选的公海舰队)的情绪追踪、等待和扣减。 + 在战役开始前检查情绪是否足够,在战斗后扣减情绪值, + 并在情绪不足时延迟任务执行。 + + Attributes: + total_reduced (int): 本轮运行中累计扣减的情绪值,用于触发客户端 bug 重启。 + map_is_2x_book (bool): 是否使用二倍经验书(影响情绪扣减量)。 + fleet_1 (FleetEmotion): 第一舰队的情绪追踪器。 + fleet_2 (FleetEmotion): 第二舰队的情绪追踪器。 + using_public (bool): 是否使用公海舰队统一情绪管理。 + """ total_reduced = 0 map_is_2x_book = False @@ -236,11 +282,11 @@ def record(self): def show(self): if self.using_public: - logger.attr(f'Emotion PublicFleet', self.public_fleet.value) + logger.attr(f'情绪公海舰队', self.public_fleet.value) return - + for fleet in self.fleets: - logger.attr(f'Emotion fleet_{fleet.fleet}', fleet.value) + logger.attr(f'情绪舰队_{fleet.fleet}', fleet.value) @property def reduce_per_battle(self): @@ -271,7 +317,7 @@ def _check_reduce(self, battle): """ if self.using_public: reduce = battle * self.reduce_per_battle_before_entering - logger.info(f'Expect emotion reduce: {reduce}') + logger.info(f'[情绪-检查] 预期情绪扣减: {reduce}') self.update() self.record() @@ -294,7 +340,7 @@ def _check_reduce(self, battle): raise ScriptError(f'Unknown fleet order: {method}') battle = tuple(np.array(battle) * self.reduce_per_battle_before_entering) - logger.info(f'Expect emotion reduce: {battle}') + logger.info(f'[情绪-检查] 预期情绪扣减: {battle}') self.update() self.record() @@ -317,9 +363,9 @@ def check_reduce(self, battle): recovered, delay = self._check_reduce(battle) if delay: - logger.info('Delay current task to prevent emotion control in the future') + logger.info('[情绪-延迟] 延迟当前任务以防止未来的情绪控制问题') self.config.task_delay(target=recovered) - raise ScriptEnd('Emotion control') + raise ScriptEnd('[情绪-延迟] 情绪控制') def wait(self, fleet_index): """等待指定舰队的情绪恢复。应在进入任何战斗之前调用。 @@ -337,17 +383,17 @@ def wait(self, fleet_index): recovered = fleet.get_recovered(expected_reduce=self.reduce_per_battle) if recovered > current_time(): - logger.hr('Emotion wait') + logger.hr('情绪等待') if self.using_public: - logger.info(f'Emotion of PublicFleet will recover to {fleet.limit} at {recovered}') + logger.info(f'[情绪-等待] 公海舰队情绪将恢复到 {fleet.limit},时间 {recovered}') else: - logger.info(f'Emotion of fleet {fleet_index} will recover to {fleet.limit} at {recovered}') + logger.info(f'[情绪-等待] 舰队 {fleet_index} 情绪将恢复到 {fleet.limit},时间 {recovered}') while 1: if current_time() > recovered: break - logger.attr('Wait until', recovered) + logger.attr('等待直到', recovered) sleep(60) def reduce(self, fleet_index, shipwreck=False): @@ -358,7 +404,7 @@ def reduce(self, fleet_index, shipwreck=False): fleet_index (int): 舰队编号,1 或 2。 shipwreck (bool): 舰队是否遭遇船难。 """ - logger.hr('Emotion reduce') + logger.hr('情绪扣减') self.update() if self.using_public: @@ -391,10 +437,10 @@ def triggered_bug(self): """检测碧蓝航线客户端情绪计算 bug。 客户端在长时间运行后无法正确计算情绪,需要重启游戏客户端使其更新。 """ - logger.attr('Emotion_bug', f'{self.total_reduced}/{self.bug_threshold}') + logger.attr('情绪Bug', f'{self.total_reduced}/{self.bug_threshold}') if self.total_reduced >= self.bug_threshold: - logger.info('Azur Lane client does not calculate emotion correctly, which is a bug. ' - 'After a long run, we have to restart game client and let the client update it.') + logger.info('[情绪-Bug] 碧蓝航线客户端未正确计算情绪,这是一个Bug。' + '长时间运行后,需要重启游戏客户端让客户端更新情绪。') self.total_reduced = 0 self.bug_threshold_reset() return True diff --git a/module/combat/hp_balancer.py b/module/combat/hp_balancer.py index f31009ab2..9d4c76936 100644 --- a/module/combat/hp_balancer.py +++ b/module/combat/hp_balancer.py @@ -1,9 +1,27 @@ +"""战斗血量平衡管理器。 + +监控战斗中各舰船的 HP 状态,提供血量检测、撤退判断和血量平衡功能。 + +血量检测通过 HP 条的颜色分析实现: +- 绿色 HP 条:血量充足 +- 红色 HP 条:血量较低 +- 通过颜色占比计算当前血量百分比 + +功能: +- hp_get(): 从截图中读取所有舰船的 HP 百分比 +- hp_retreat_triggered(): 判断是否需要撤退(任一舰船血量过低) +- hp_reset(): 进入地图时重置 HP 数据 + +每个位置(先锋 3 个 + 主力 3 个 = 6 个)独立追踪。 +""" + from module.base.base import ModuleBase from module.base.button import * from module.base.decorator import Config from module.config.utils import to_list from module.logger import logger +# 前排侦察位置坐标(用于特定场景的 HP 检测) SCOUT_POSITION = [ (403, 421), (625, 369), @@ -12,6 +30,19 @@ class HPBalancer(ModuleBase): + """战斗血量平衡器。 + + 追踪舰队中每个位置的 HP 值,提供血量检测和撤退判断。 + 支持按舰队索引(1 或 2)分别管理。 + + Attributes: + fleet_current_index (int): 当前操作的舰队索引。 + fleet_show_index (int): 当前显示的舰队索引。 + _hp (dict[int, list[float]]): 各舰队的 HP 值缓存。 + _hp_has_ship (dict[int, list[bool]]): 各位置是否有舰船。 + COLOR_HP_GREEN (tuple): HP 条绿色部分的参考颜色。 + COLOR_HP_RED (tuple): HP 条红色部分的参考颜色。 + """ fleet_current_index = 1 fleet_show_index = 1 _hp = {} @@ -89,7 +120,7 @@ def hp_get(self): weight = self.config.HpControl_HpBalanceWeight if ',' in self.config.HpControl_HpBalanceWeight: weight = self.config.HpControl_HpBalanceWeight.replace(',', ',') - logger.info(f'HpControl_HpBalanceWeight {self.config.HpControl_HpBalanceWeight} is revised to {weight}') + logger.info(f'[血量-平衡] 血量平衡权重 {self.config.HpControl_HpBalanceWeight} 修正为 {weight}') self.config.HpControl_HpBalanceWeight = weight hp = [self._calculate_hp(button.area) for button in self._hp_grid().buttons] @@ -100,10 +131,10 @@ def hp_get(self): if self.fleet_current_index not in self._hp_has_ship: self.hp_has_ship = [bool(hp > 0.3) for hp in self.hp] - logger.attr('HP', ' '.join( + logger.attr('血量', ' '.join( [str(int(data * 100)).rjust(3) + '%' if use else '____' for data, use in zip(hp, self.hp_has_ship)])) if np.sum(np.abs(np.diff(weight))) > 0: - logger.attr('HP_weight', ' '.join([str(int(data * 100)).rjust(3) + '%' for data in self.hp])) + logger.attr('血量权重', ' '.join([str(int(data * 100)).rjust(3) + '%' for data in self.hp])) return self.hp @@ -119,7 +150,7 @@ def _scout_position_change(self, p1, p2): p1 (int): 原始位置 [0, 2]。 p2 (int): 目标位置 [0, 2]。 """ - logger.info('scout_position_change (%s, %s)' % (p1, p2)) + logger.info('[血量-平衡] 侦察位置交换 (%s, %s)' % (p1, p2)) self.device.drag(p1=SCOUT_POSITION[p1], p2=SCOUT_POSITION[p2], segments=3) def _expected_scout_order(self, hp): @@ -154,7 +185,7 @@ def _expected_scout_order(self, hp): # 80% 0% 0% order = [0, 1, 2] else: - logger.warning(f'HP invalid: {hp}') + logger.warning(f'[血量-平衡] 血量无效: {hp}') order = [0, 1, 2] return order @@ -231,7 +262,7 @@ def hp_retreat_triggered(self): if self.config.HpControl_UseLowHpRetreat: hp = np.array(self.hp)[self.hp_has_ship] if np.any(hp < self.config.HpControl_LowHpRetreatThreshold): - logger.info('Low HP retreat triggered.') + logger.info('[血量-撤退] 低血量撤退触发') return True return False diff --git a/module/combat/level.py b/module/combat/level.py index dd8f23d23..885ae7e55 100644 --- a/module/combat/level.py +++ b/module/combat/level.py @@ -1,3 +1,19 @@ +"""舰船等级检测模块。 + +通过 OCR 识别战斗画面中各舰船的等级信息。 + +等级检测的使用场景: +- 等级停止条件:当任一舰船达到目标等级时停止战役 +- LV.32 检测:当旗舰达到 32 级时停止(钻石 farming 场景) + +等级显示格式为 "LV.XX",OCR 前需要: +1. 去除 "LV." 前缀,仅保留数字部分 +2. 处理低血量时的遮罩效果(颜色偏暗) +3. 处理半透明蓝色背景 + +等级数据按 6 个位置独立追踪(先锋 3 + 主力 3)。 +""" + import module.config.server as server from module.base.base import ModuleBase @@ -6,11 +22,20 @@ from module.logger import logger from module.ocr.ocr import Digit +# 白色和遮罩后的参考颜色 COLOR_WHITE = (255, 255, 255) COLOR_MASKED = (107, 105, 107) class Level(ModuleBase): + """舰船等级检测器。 + + 通过 OCR 读取战斗画面中各位置舰船的等级,并提供等级停止条件判断。 + + Attributes: + _lv (list[int]): 各位置的当前等级,-1 表示未检测。 + _lv_before_battle (list[int]): 战斗前的等级快照,用于检测升级。 + """ _lv = [-1, -1, -1, -1, -1, -1] _lv_before_battle = [-1, -1, -1, -1, -1, -1] @@ -63,7 +88,7 @@ def lv_get(self, after_battle=False): ocr = LevelOcr(self._lv_grid().buttons, name='LevelOcr') self.lv = ocr.ocr(self.device.image) - logger.attr('LEVEL', ', '.join(str(data) for data in self.lv)) + logger.attr('等级', ', '.join(str(data) for data in self.lv)) if after_battle: self.lv_triggered() @@ -79,15 +104,15 @@ def lv_triggered(self): for i in range(6): before, after = self._lv_before_battle[i], self.lv[i] if after > before > 0: - logger.info(f'Position {i} LV.{before} -> LV.{after}') + logger.info(f'[等级-检测] 位置 {i} 等级.{before} -> 等级.{after}') if after >= limit > before > 0: if after - before == 1 or after < 35: - logger.info(f'Position {i} LV.{limit} Reached') + logger.info(f'[等级-检测] 位置 {i} 等级.{limit} 已达到') self.config.LV_TRIGGERED = True return True else: - logger.warning(f'Level gap between {before} and {after} is too large. ' - f'This will not be considered as a trigger') + logger.warning(f'[等级-检测] {before} 和 {after} 之间的等级差距过大。' + f'这不会被视为触发条件') return False @@ -96,7 +121,7 @@ def lv32_triggered(self): return False if self.lv[0] >= 32: - logger.info(f'Position 0 LV.32 Reached') + logger.info('[等级-检测] 位置 0 等级.32 已达到') self.config.LV32_TRIGGERED = True return True diff --git a/module/combat/submarine.py b/module/combat/submarine.py index eb7edb32c..4c7e360f2 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -1,3 +1,19 @@ +"""潜艇呼叫管理模块。 + +管理战斗中的潜艇呼叫操作。 + +潜艇呼叫模式: +- do_not_use: 不使用潜艇 +- hunt_only: 仅狩猎模式(潜艇自动攻击范围内敌人) +- boss_only: 仅 Boss 战呼叫潜艇 +- hunt_and_boss: 狩猎 + Boss 战都使用潜艇 + +潜艇呼叫需要消耗潜艇弹药,弹药耗尽后无法呼叫。 +呼叫时机由自动搜索设置中的潜艇角色配置决定。 + +继承自 ModuleBase,被 Combat 组合使用。 +""" + from module.base.base import ModuleBase from module.base.timer import Timer from module.combat.assets import * @@ -5,6 +21,15 @@ class SubmarineCall(ModuleBase): + """潜艇呼叫管理器。 + + 在战斗中控制潜艇的呼叫时机和状态。 + + Attributes: + submarine_call_flag (bool): 本次战斗是否已呼叫过潜艇。 + submarine_call_timer (Timer): 潜艇呼叫检测计时器。 + submarine_call_click_timer (Timer): 潜艇呼叫点击间隔计时器。 + """ submarine_call_flag = False submarine_call_timer = Timer(5) submarine_call_click_timer = Timer(1) @@ -29,7 +54,7 @@ def handle_submarine_call(self, submarine='do_not_use', call=False): self.submarine_call_flag = True return False if self.submarine_call_timer.reached(): - logger.info('Submarine call timer reached') + logger.info('潜艇呼叫计时器到达') self.submarine_call_flag = True return False @@ -37,13 +62,13 @@ def handle_submarine_call(self, submarine='do_not_use', call=False): return False if self.appear(SUBMARINE_CALLED): - logger.info('Submarine called') + logger.info('潜艇已呼叫') self.submarine_call_flag = True return False elif self.submarine_call_click_timer.reached(): if not self.appear_then_click(SUBMARINE_READY): - logger.info('Incorrect submarine icon') + logger.info('错误的潜艇图标') self.device.click(SUBMARINE_READY) - logger.info('Call submarine') + logger.info('呼叫潜艇') self.submarine_call_click_timer.reset() return True diff --git a/module/combat_ui/assets.py b/module/combat_ui/assets.py index ef6ac985c..25deb783d 100644 --- a/module/combat_ui/assets.py +++ b/module/combat_ui/assets.py @@ -1,3 +1,5 @@ +"""战斗UI模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/commission/assets.py b/module/commission/assets.py index c643e1bba..8518101be 100644 --- a/module/commission/assets.py +++ b/module/commission/assets.py @@ -1,3 +1,5 @@ +"""委托模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/commission/commission.py b/module/commission/commission.py index 682a55bb2..1deb9c91b 100644 --- a/module/commission/commission.py +++ b/module/commission/commission.py @@ -1,3 +1,25 @@ +"""委托任务执行模块。 + +负责碧蓝航线委托系统的自动化处理,包括委托奖励领取、委托检测、 +委托选择过滤、委托启动以及奖励收入统计。支持每日委托和紧急委托 +两大类别,通过 OCR 和图像匹配识别委托信息,并根据用户配置的 +过滤规则自动选择最优委托组合。 + +主要流程: + 1. 从奖励页面进入委托页面 + 2. 领取已完成的委托奖励(commission_receive) + 3. 扫描当前所有委托列表(_commission_scan_all) + 4. 根据过滤规则选择待启动的委托(_commission_choose) + 5. 逐一查找并启动选中的委托(commission_start) + 6. 根据运行中委托的完成时间计算下次调度 + +依赖: + - module.commission.project: 委托信息解析(Commission 类) + - module.commission.preset: 预设过滤规则 + - module.ui.ui: 页面导航 + - module.handler.info_handler: 弹窗/信息栏处理 +""" + import copy from datetime import timedelta @@ -36,12 +58,16 @@ def lines_detect(image): - """ + """检测委托列表中各委托条目底部的白色分割线位置。 + + 通过分析截图中分割线区域(x: 597-619)的灰度均值, + 使用 scipy.signal.find_peaks 定位白色线条的 Y 坐标。 + Args: - image: + image (np.ndarray): 游戏截图。 Returns: - np.ndarray: 每个委托下方白色分割线的 Y 坐标。 + np.ndarray: 每个委托下方白色分割线的 Y 坐标数组。 """ # 通过查找每个委托下方的白色分割线来定位委托位置。 # (597, 0, 619, 720) 是只有白色分割线的区域。 @@ -55,6 +81,22 @@ def lines_detect(image): class RewardCommission(UI, InfoHandler): + """委托任务处理器。 + + 继承 UI 和 InfoHandler,负责委托系统的完整自动化流程, + 包括委托检测、过滤选择、启动执行和奖励领取。 + + Attributes: + daily (SelectedGrids): 当前扫描到的每日委托列表。 + urgent (SelectedGrids): 当前扫描到的紧急委托列表。 + daily_choose (SelectedGrids): 经过滤器选中的待启动每日委托。 + urgent_choose (SelectedGrids): 经过滤器选中的待启动紧急委托。 + comm_choose (SelectedGrids): 所有选中的委托(含每日和紧急), + 用于调度判断和延迟任务计算。 + max_commission (int): 最大可同时运行的委托数量,默认 4。 + 当存在活动委托(daily_event)时提升为 5。 + """ + daily: SelectedGrids urgent: SelectedGrids daily_choose: SelectedGrids @@ -72,11 +114,11 @@ def _commission_detect(self, image): Returns: SelectedGrids: """ - logger.hr('Commission detect') + logger.hr('委托检测') commission = [] for y in lines_detect(image): comm = Commission(image, y=y, config=self.config) - logger.attr('Commission', comm) + logger.attr('委托', comm) repeat = len([c for c in commission if c == comm]) comm.repeat_count += repeat commission.append(comm) @@ -107,12 +149,12 @@ def commission_detect(self, trial=1, area=None, skip_first_screenshot=True): commissions = self._commission_detect(image) if commissions.count >= 2 and commissions.select(valid=False).count == 1: - logger.warning('Found 1 invalid commission, retry commission detect') + logger.warning('[委托-检测] 发现1个无效委托,重试委托检测') continue else: return commissions - logger.info('trials of commission detect exhausted, stop') + logger.info('[委托-检测] 委托检测重试次数已耗用,停止') return commissions def _commission_choose(self, daily, urgent): @@ -136,7 +178,7 @@ def _commission_choose(self, daily, urgent): self.max_commission = 5 running_list = [c for c in total if c.status == 'running'] running_count = len(running_list) - logger.attr('Running', f'{running_count}/{self.max_commission}') + logger.attr('运行中', f'{running_count}/{self.max_commission}') # 加载过滤器字符串 preset = self.config.Commission_PresetFilter @@ -149,32 +191,32 @@ def _commission_choose(self, daily, urgent): if start_time < end_time: preset = f'{preset}_night' if preset not in DICT_FILTER_PRESET: - logger.warning(f'Preset not found: {preset}, use default preset') + logger.warning(f'[委托-过滤] 预设未找到: {preset},使用默认预设') preset = GeneratedConfig.Commission_PresetFilter string = DICT_FILTER_PRESET[preset] - logger.attr('Commission Filter', preset) + logger.attr('委托过滤器', preset) # 过滤 COMMISSION_FILTER.load(string) run = COMMISSION_FILTER.apply(total.grids, func=self._commission_check) - logger.attr('Filter_sort', ' > '.join([str(c) for c in run])) + logger.attr('过滤排序', ' > '.join([str(c) for c in run])) run = SelectedGrids(run) # 添加最短时间委托 if self.config.Commission_AddShortest == False and preset == 'custom': - logger.info('Not enough commissions to run') + logger.info('[委托-选择] 没有足够的委托可运行') else: no_shortest = run.delete(SelectedGrids(['shortest'])) if no_shortest.count + running_count < self.max_commission: if daily.count: - logger.info('Not enough commissions to run, add shortest daily commissions') + logger.info('[委托-选择] 没有足够的委托可运行,添加最短时间的每日委托') COMMISSION_FILTER.load(SHORTEST_FILTER) shortest = COMMISSION_FILTER.apply(daily[::-1], func=self._commission_check) # 反转每日委托列表以选择更好的委托 run = no_shortest.add_by_eq(SelectedGrids(shortest)) - logger.attr('Filter_sort', ' > '.join([str(c) for c in run])) + logger.attr('过滤排序', ' > '.join([str(c) for c in run])) else: - logger.info('Not enough commissions to run') + logger.info('[委托-选择] 没有足够的委托可运行') # 优先处理快过期重要委托 if 'expire' in run: @@ -190,14 +232,14 @@ def _commission_choose(self, daily, urgent): min_duration_time = c.duration else: min_duration_time = timedelta(seconds=0) - logger.attr('Min Duration Time', min_duration_time) + logger.attr('最短时长', min_duration_time) expire_index = run.grids.index('expire') important = run[:expire_index].filter(lambda c: isinstance(c, Commission) and c.expire) priority = [c for c in important if c.expire < min_duration_time] run = run.delete(SelectedGrids(['expire'])) run = SelectedGrids(priority).add_by_eq(run) - logger.attr('Filter_sort', ' > '.join([str(c) for c in run])) + logger.attr('过滤排序', ' > '.join([str(c) for c in run])) self.comm_choose = run if running_count >= self.max_commission: @@ -208,23 +250,27 @@ def _commission_choose(self, daily, urgent): daily_choose = run.intersect_by_eq(daily) urgent_choose = run.intersect_by_eq(urgent) if daily_choose: - logger.info('Choose daily commission') + logger.info('[委托-选择] 选择每日委托') for comm in daily_choose: logger.info(comm) if urgent_choose: - logger.info('Choose urgent commission') + logger.info('[委托-选择] 选择紧急委托') for comm in urgent_choose: logger.info(comm) return daily_choose, urgent_choose def _commission_check(self, commission): - """ + """检查委托是否符合执行条件。 + + 过滤掉无效委托、非待启动状态的委托,以及用户配置中 + 明确禁用的主线委托(major commission)。 + Args: - commission (Commission): + commission (Commission): 待检查的委托对象。 Returns: - bool: + bool: 委托是否可以被选择执行。 """ if not commission.valid or commission.status != 'pending': return False @@ -234,6 +280,17 @@ def _commission_check(self, commission): return True def _commission_ensure_mode(self, mode): + """切换委托列表的显示模式(每日/紧急)。 + + 切换到指定模式后,等待列表滚动动画结束再返回, + 以避免委托条目在动画过程中被误检或漏检。 + + Args: + mode (str): 目标模式,'daily' 或 'urgent'。 + + Returns: + bool: 切换是否成功。 + """ if COMMISSION_SWITCH.set(mode, main=self): # 当每日委托列表超过 4 个(通常为 5 个),且紧急委托在 1 到 4 个之间时, # 委托列表会出现滚动动画, @@ -253,13 +310,21 @@ def _commission_ensure_mode(self, mode): return False def _commission_mode_reset(self): - logger.hr('Commission mode reset') + """重置委托列表显示模式。 + + 先切换到另一个模式再切回当前模式,强制刷新列表内容, + 用于委托启动失败后恢复列表状态。 + + Returns: + bool: 重置是否成功。无法识别当前模式时返回 False。 + """ + logger.hr('委托模式重置') if self.appear(COMMISSION_DAILY): current, another = 'daily', 'urgent' elif self.appear(COMMISSION_URGENT): current, another = 'urgent', 'daily' else: - logger.warning('Unknown Commission mode') + logger.warning('[委托-模式] 未知的委托模式') return False self._commission_ensure_mode(another) @@ -268,6 +333,13 @@ def _commission_mode_reset(self): return True def _commission_swipe(self): + """向下翻页委托列表。 + + 如果滚动条可见且未到底部,则向下翻一页;否则返回 False。 + + Returns: + bool: 是否成功翻页。滚动条不可见或已到底部时返回 False。 + """ if COMMISSION_SCROLL.appear(main=self): if COMMISSION_SCROLL.at_bottom(main=self): return False @@ -278,6 +350,11 @@ def _commission_swipe(self): return False def _commission_swipe_to_top(self): + """将委托列表滚动到顶部。 + + Returns: + bool: 是否执行了滚动操作。滚动条不可见时返回 False。 + """ if not COMMISSION_SCROLL.appear(main=self): return False COMMISSION_SCROLL.set_top(main=self, skip_first_screenshot=True) @@ -307,18 +384,18 @@ def _commission_scan_all(self): in: page_commission out: page_commission """ - logger.hr('Commission scan', level=1) + logger.hr('委托扫描', level=1) # 紧急委托列表是懒加载的,先切换以强制刷新。 self._commission_ensure_mode('urgent') - logger.hr('Scan daily', level=2) + logger.hr('扫描每日委托', level=2) self._commission_ensure_mode('daily') self._commission_swipe_to_top() daily = self._commission_scan_list() urgent = SelectedGrids([]) for _ in range(2): - logger.hr('Scan urgent', level=2) + logger.hr('扫描紧急委托', level=2) self._commission_ensure_mode('urgent') self._commission_swipe_to_top() urgent = self._commission_scan_list() @@ -330,10 +407,10 @@ def _commission_scan_all(self): if current_time() - get_server_next_update('21:00') > timedelta(hours=6): night = urgent.select(category_str='night') if night: - logger.warning('Not in 21:00~03:00, but scanned night commissions') + logger.warning('[委托-扫描] 不在21:00~03:00时间段,但扫描到夜间委托') for comm in night: - logger.attr('Commission', comm) - logger.info('Re-scan urgent commission list') + logger.attr('委托', comm) + logger.info('[委托-扫描] 重新扫描紧急委托列表') # 虽然不是最佳方式,但在罕见情况下可以接受 self.device.sleep(2) self._commission_ensure_mode('daily') @@ -341,14 +418,14 @@ def _commission_scan_all(self): break - logger.hr('Showing commission', level=2) - logger.info('Daily commission') + logger.hr('显示委托', level=2) + logger.info('[委托-显示] 每日委托') for comm in daily.sort('status', 'genre'): - logger.attr('Commission', comm) + logger.attr('委托', comm) if urgent.count: - logger.info('Urgent commission') + logger.info('[委托-显示] 紧急委托') for comm in urgent.sort('status', 'genre'): - logger.attr('Commission', comm) + logger.attr('委托', comm) self.daily = daily self.urgent = urgent @@ -371,7 +448,7 @@ def _commission_start_click(self, comm, is_urgent=False, skip_first_screenshot=T in: page_commission out: page_commission, info_bar, commission details unfold """ - logger.hr('Commission start') + logger.hr('启动委托') self.interval_clear(COMMISSION_ADVICE) self.interval_clear(COMMISSION_START) comm_timer = Timer(7) @@ -389,8 +466,8 @@ def _commission_start_click(self, comm, is_urgent=False, skip_first_screenshot=T # 重启游戏以处理委托推荐 bug。 # 点击"推荐"后,舰船出现后突然消失。 # 同时委托图标闪烁。 - logger.warning('Triggered commission list flashing bug') - raise GameStuckError('Triggered commission list flashing bug') + logger.warning('[委托-启动] 触发了委托列表闪烁bug') + raise GameStuckError('[委托-启动] 触发了委托列表闪烁bug') # 点击 if self.match_template_color(COMMISSION_START, offset=(5, 20), interval=7): @@ -404,7 +481,7 @@ def _commission_start_click(self, comm, is_urgent=False, skip_first_screenshot=T continue # 误入船坞 if self.appear(DOCK_CHECK, offset=(20, 20), interval=3): - logger.info(f'equip_enter {DOCK_CHECK} -> {BACK_ARROW}') + logger.info(f'[委托-启动] 误入船坞 {DOCK_CHECK} -> {BACK_ARROW}') self.device.click(BACK_ARROW) comm_timer.reset() continue @@ -417,12 +494,12 @@ def _commission_start_click(self, comm, is_urgent=False, skip_first_screenshot=T if current.count >= 1: current = current[0] if current == comm: - logger.info('Selected to the correct commission') + logger.info('[委托-启动] 已选择正确的委托') else: - logger.warning('Selected to the wrong commission') + logger.warning('[委托-启动] 选择了错误的委托') return False else: - logger.warning('No selected commission detected, assuming correct') + logger.warning('[委托-启动] 未检测到选择的委托,假设正确') self.device.click(COMMISSION_ADVICE) count += 1 self.interval_reset(COMMISSION_ADVICE) @@ -447,8 +524,8 @@ def _commission_find_and_start(self, comm, is_urgent=False): comm = copy.deepcopy(comm) comm.repeat_count = 1 for _ in range(3): - logger.hr('Commission find and start', level=2) - logger.info(f'Finding commission {comm}') + logger.hr('查找并启动委托', level=2) + logger.info(f'[委托-查找] 正在查找委托 {comm}') failed = True @@ -478,17 +555,17 @@ def _commission_find_and_start(self, comm, is_urgent=False): break if failed: - logger.warning(f'Failed to select commission: {comm}') + logger.warning(f'[委托-查找] 选择委托失败: {comm}') self._commission_mode_reset() self._commission_swipe_to_top() self.device.click_record_clear() continue else: - logger.warning(f'Commission not found: {comm}') + logger.warning(f'[委托-查找] 未找到委托: {comm}') self.device.click_record_clear() return False - logger.warning(f'Failed to select commission after 3 trial') + logger.warning('[委托-查找] 尝试3次后仍无法选择委托') self.device.click_record_clear() return False @@ -502,7 +579,7 @@ def commission_start(self): """ self._commission_scan_all() - logger.hr('Commission run', level=1) + logger.hr('执行委托', level=1) if self.daily_choose: for comm in self.daily_choose: self._commission_ensure_mode('daily') @@ -520,7 +597,7 @@ def commission_start(self): comm.convert_to_running() self._commission_mode_reset() if not self.daily_choose and not self.urgent_choose: - logger.info('No commission chose') + logger.info('[委托-执行] 没有选择任何委托') def _record_commission_income(self): """ @@ -543,7 +620,7 @@ def _record_commission_income(self): template_folder = os.path.join('.', 'assets', 'stats_commission_items') if not os.path.exists(template_folder): - logger.info('Commission income: template folder not found, skip') + logger.info('[委托-收入] 模板文件夹不存在,跳过') return grid = ItemGrid(None, {}, template_area=(40, 21, 89, 70), amount_area=(50, 71, 91, 92)) @@ -552,7 +629,7 @@ def _record_commission_income(self): grid.load_template_folder(template_folder) if not grid.templates: - logger.info('Commission income: no templates loaded, skip') + logger.info('[委托-收入] 没有加载模板,跳过') return get_items = GetItemsStatistics() @@ -562,7 +639,7 @@ def _record_commission_income(self): images = getattr(self, '_commission_reward_images', None) if not images: - logger.info('Commission income: no reward images collected') + logger.info('[委托-收入] 没有收集到奖励截图') return COMMISSION_TRACKED_ITEMS = ['Gem', 'Cube', 'Chip', 'Oil', 'Coin'] @@ -574,11 +651,11 @@ def _record_commission_income(self): 'Coins': 'Coin', } - logger.info(f'Commission income: processing {len(images)} reward screenshot(s)') + logger.info(f'[委托-收入] 处理 {len(images)} 张奖励截图') for idx, image in enumerate(images): try: if INFO_BAR_1.appear_on(image): - logger.info(f'Commission income: screenshot[{idx}] has info_bar, skip') + logger.info(f'[委托-收入] 截图[{idx}] 有信息栏,跳过') continue grid.grids = None if GET_ITEMS_1.match_template_color(image, offset=(5, 0)): @@ -589,7 +666,7 @@ def _record_commission_income(self): elif GET_ITEMS_3.match_template_color(image, offset=(5, 0)): grid.grids = ITEM_GRIDS_3 else: - logger.info(f'Commission income: screenshot[{idx}] not a get_items page, skip') + logger.info(f'[委托-收入] 截图[{idx}] 不是获取物品页面,跳过') continue grid.predict(image) recognized = [] @@ -597,24 +674,24 @@ def _record_commission_income(self): if item.is_known_item() and item.name not in ('DefaultItem',): mapped_name = COMMISSION_ITEM_NAME_MAP.get(item.name, item.name) if mapped_name not in COMMISSION_TRACKED_ITEMS: - logger.info(f'Commission income: screenshot[{idx}] ignored {item.name} (not tracked)') + logger.info(f'[委托-收入] 截图[{idx}] 忽略 {item.name} (未跟踪)') continue merged_items[mapped_name] = merged_items.get(mapped_name, 0) + item.amount item_count += 1 recognized.append(f'{mapped_name}x{item.amount}') if recognized: - logger.info(f'Commission income: screenshot[{idx}] recognized {len(recognized)} item(s): {", ".join(recognized)}') + logger.info(f'[委托-收入] 截图[{idx}] 识别到 {len(recognized)} 个物品: {", ".join(recognized)}') else: - logger.info(f'Commission income: screenshot[{idx}] no known items recognized') + logger.info(f'[委托-收入] 截图[{idx}] 没有识别到已知物品') except Exception as e: - logger.info(f'Commission income: screenshot[{idx}] recognition failed: {e}') + logger.info(f'[委托-收入] 截图[{idx}] 识别失败: {e}') continue if merged_items: instance = self.config.config_name cl1_db.add_commission_income(instance, merged_items, commission_count=1) item_str = ', '.join([f'{k}x{v}' for k, v in merged_items.items()]) - logger.info(f'Commission income recorded: {item_str} (instance={instance})') + logger.info(f'[委托-收入] 委托收入记录: {item_str} (实例={instance})') if self.config.Commission_CommissionNotifyReward: reward_stats = None if self.config.Commission_CommissionNotifyRewardStatistics: @@ -656,12 +733,20 @@ def _record_commission_income(self): ) else: - logger.info('Commission income: no known items recognized from all screenshots') + logger.info('[委托-收入] 所有截图都没有识别到已知物品') except Exception as e: - logger.warning(f'Commission income recording failed: {e}') + logger.warning(f'[委托-收入] 委托收入记录失败: {e}') def _handle_research_genre_t_update(self, completed_commission_count): + """更新 T 类科研任务的剩余委托计数。 + + 当存在 T 类科研(要求完成指定次数委托)时,将已完成的委托次数 + 从剩余计数中扣除。计数归零时触发科研任务调度。 + + Args: + completed_commission_count (int): 本次领取奖励时完成的委托数量。 + """ if completed_commission_count <= 0: return required_commissions = self.config.cross_get('Research.Research.RemainingCommissions', -1) @@ -676,7 +761,22 @@ def _handle_research_genre_t_update(self, completed_commission_count): self.config.task_call('Research') def _commission_receive(self, skip_first_screenshot=True): - logger.hr('Reward receive') + """领取已完成的委托奖励。 + + 在委托页面和奖励页面之间循环,点击所有可领取的奖励弹窗 + (经验、物品、舰船),同时收集奖励截图用于收入统计。 + 处理石油溢出的情况(触发宿舍喂食消耗石油)。 + + Args: + skip_first_screenshot (bool): 是否跳过首次截图,复用上一状态的截图。 + + Returns: + bool: 是否领取了任何奖励。 + + Raises: + OilMaxed: 石油溢出且喂食 3 次仍无法解决时抛出。 + """ + logger.hr('领取奖励') reward = False click_timer = Timer(1) @@ -710,7 +810,7 @@ def _commission_receive(self, skip_first_screenshot=True): self._commission_reward_images = [] else: self._commission_reward_images.append(self.device.image.copy()) - logger.info(f'Commission income: collected reward screenshot (trigger={button.name})') + logger.info(f'[委托-收入] 收集奖励截图 (触发按钮={button.name})') REWARD_SAVE_CLICK.name = button.name self.device.click(REWARD_SAVE_CLICK) @@ -779,11 +879,11 @@ def commission_receive(self): try: return self._commission_receive() except OilMaxed: - logger.info("Oil maxed, buy food to consume oil") + logger.info("[委托-石油] 石油溢出,购买食物消耗石油") RewardDorm(self.config, self.device).dorm_food_run(amount=10) self.ui_ensure(page_reward) - logger.critical(f'Failed to handle oil maxed after 3 trial') + logger.critical('[委托-石油] 尝试3次后仍无法处理石油溢出') raise RequestHumanTakeover def run(self): @@ -797,7 +897,7 @@ def run(self): # 选择 BACK_ARROW,但从该页面无法导航到 page_reward self.device.screenshot() if self.appear(TACTICAL_CLASS_START, offset=(30, 30)): - logger.info('Detected TACTICAL_CLASS_START, clicking cancel to exit') + logger.info('[委托-战术] 检测到战术课堂开始按钮,点击取消退出') self.device.click(TACTICAL_CLASS_CANCEL) self.device.sleep((0.5, 1.0)) self.ui_ensure(page_reward) @@ -811,11 +911,11 @@ def run(self): # 调度 total = self.daily.add_by_eq(self.urgent) future_finish = sorted([f for f in total.get('finish_time') if f is not None]) - logger.info(f'Commission finish: {[str(f) for f in future_finish]}') + logger.info(f'[委托-完成] 委托完成时间: {[str(f) for f in future_finish]}') if len(future_finish): self.config.task_delay(target=future_finish) else: - logger.info('No commission running') + logger.info('[委托-完成] 没有正在运行的委托') self.config.task_delay(success=False) # 延迟钻石 farming / 三油低耗任务 @@ -830,13 +930,13 @@ def run(self): daily = self.daily.select(category_str='daily', status='pending').count filtered_urgent = self.comm_choose.intersect_by_eq(self.urgent.select(status='pending')).count filtered_extra = self.comm_choose.intersect_by_eq(self.daily.select(category_str='extra', status='pending')).count - logger.info(f'Daily commission: {daily}, filtered_urgent: {filtered_urgent}, filtered_extra: {filtered_extra}') + logger.info(f'[委托-调度] 每日委托: {daily}, 过滤紧急: {filtered_urgent}, 过滤额外: {filtered_extra}') future = nearest_future(future_finish) if len(future_finish) else None if daily > 0 and filtered_urgent >= 1: for task in limit_tasks: - logger.info(f"Having daily commissions to do, delay task '{task}'") + logger.info(f"[委托-调度] 有待执行的每日委托,延迟任务 '{task}'") self.config.task_delay(minute=None if future else 120, target=future, task=task) elif filtered_urgent >= 4: for task in limit_tasks: - logger.info(f"Having too many urgent commissions, delay task '{task}'") + logger.info(f"[委托-调度] 紧急委托过多,延迟任务 '{task}'") self.config.task_delay(minute=None if future else 120, target=future, task=task) diff --git a/module/commission/preset.py b/module/commission/preset.py index b1fe06a38..5c89caee8 100644 --- a/module/commission/preset.py +++ b/module/commission/preset.py @@ -1,3 +1,7 @@ +"""委托筛选预设配置,定义不同场景下的委托优先级排序方案。 +包含最短时间筛选、芯片/魔方/装备箱等专用预设, +以及对应的夜间模式变体和通用默认筛选规则。""" + SHORTEST_FILTER = """ 0:20 > 0:30 > 1 > 1:10 > 1:20 > 1:30 > 1:40 > 1:45 diff --git a/module/commission/project.py b/module/commission/project.py index e08f4ea91..554cbd93c 100644 --- a/module/commission/project.py +++ b/module/commission/project.py @@ -1,3 +1,20 @@ +"""委托信息解析模块。 + +负责从委托界面截图中解析单条委托的全部属性,包括名称 OCR 识别、 +委托类型匹配、执行时长解析、状态判断和后缀图像提取。 + +核心类 Commission 封装了一条委托的所有信息,并通过 @Config.when +装饰器为 CN/EN/JP/TW 四个服务器分别实现不同的解析逻辑。 + +本模块还定义了 COMMISSION_FILTER 过滤器实例,用于根据用户配置的 +规则(如 'daily_resource-01:30')筛选和排序委托列表。 + +依赖: + - module.base.filter: 正则过滤器框架 + - module.ocr.ocr: OCR 文字识别(Duration、Ocr) + - module.commission.project_data: 各服务器的委托名称字典 +""" + from datetime import timedelta from module.base.decorator import Config @@ -463,7 +480,7 @@ def parse_time(self, string): string = string.replace('D', '0') result = re.search('(\d+):(\d+):(\d+)', string) if not result: - logger.warning(f'Invalid time string: {string}') + logger.warning(f'无效的时间字符串: {string}') self.valid = False return None else: @@ -489,7 +506,7 @@ def commission_name_parse(self, string): if keyword in string: return key - logger.warning(f'Name with unknown genre: {string}') + logger.warning(f'未知类型的名称: {string}') self.valid = False return '' @@ -522,7 +539,7 @@ def commission_name_parse(self, string): if min_distance < 3: return min_key - logger.warning(f'Name with unknown genre: {string}') + logger.warning(f'未知类型的名称: {string}') self.valid = False return '' @@ -545,7 +562,7 @@ def commission_name_parse(self, string): if keyword in string: return key - logger.warning(f'Name with unknown genre: {string}') + logger.warning(f'未知类型的名称: {string}') self.valid = False return '' @@ -568,7 +585,7 @@ def commission_name_parse(self, string): if keyword in string: return key - logger.warning(f'Name with unknown genre: {string}') + logger.warning(f'未知类型的名称: {string}') self.valid = False return '' diff --git a/module/commission/project_data.py b/module/commission/project_data.py index dd4670605..bf24bc76e 100644 --- a/module/commission/project_data.py +++ b/module/commission/project_data.py @@ -1,3 +1,7 @@ +"""委托项目数据字典,定义各语言(CN/EN/JP/TW)下委托名称的关键词映射。 +用于通过 OCR 识别委托标题并自动分类为日常、紧急、额外等类型, +支持多语言委托名称的模糊匹配。""" + dictionary_cn = { 'major_comm': ['自主训练', '对抗演习', '科研任务', '工具整备', '战术课程', '货物运输'], 'daily_resource': ['日常资源开发'], diff --git a/module/config/code_generator.py b/module/config/code_generator.py index 9cf3f54fb..3b416421f 100644 --- a/module/config/code_generator.py +++ b/module/config/code_generator.py @@ -1,3 +1,9 @@ +"""代码生成器模块。 + +提供带缩进管理的代码生成器 CodeGenerator 和上下文管理器 TabWrapper, +用于自动生成 Python 配置代码(如 config_generated.py)。 +""" + import typing as t diff --git a/module/config/config.py b/module/config/config.py index cbc1b070a..aba0c2358 100644 --- a/module/config/config.py +++ b/module/config/config.py @@ -1,3 +1,10 @@ +"""配置管理核心模块。 + +定义 AzurLaneConfig 类,从 JSON 配置文件加载用户设置并与模板合并。 +整合 ConfigUpdater、ManualConfig、GeneratedConfig、ConfigWatcher, +支持配置热重载、版本迁移和任务级配置绑定。 +""" + import copy import operator import os @@ -22,10 +29,26 @@ class TaskEnd(Exception): + """任务提前结束异常。 + + 当检测到需要延迟任务(如情绪不足)时抛出, + 由调度循环捕获并安排延迟重试。 + """ pass class Function: + """任务调度函数描述对象。 + + 描述一个可调度任务的基本属性:是否启用、命令名称和下次执行时间。 + 用于任务调度器的优先级排序和执行选择。 + + Attributes: + enable (bool): 任务是否启用。 + command (str): 任务命令名称,如 'Research'、'Commission'。 + next_run (datetime): 下次计划执行时间。 + """ + def __init__(self, data): self.enable = deep_get(data, keys="Scheduler.Enable", default=False) self.command = deep_get(data, keys="Scheduler.Command", default="Unknown") @@ -64,6 +87,33 @@ def name_to_function(name): class AzurLaneConfig(ConfigUpdater, ManualConfig, GeneratedConfig, ConfigWatcher): + """碧蓝航线自动化配置管理器。 + + 项目的核心配置类,通过多重继承组合了: + - ConfigUpdater: 配置版本升级和默认值合并 + - ManualConfig: 手动配置的属性访问 + - GeneratedConfig: 自动生成的配置属性(从 template.json 生成) + - ConfigWatcher: 配置文件变更检测 + + 配置加载流程: + 1. 从 `config/{config_name}.json` 读取用户配置 + 2. 与 args.json 默认值合并(ConfigUpdater.config_update) + 3. 执行版本迁移重定向(ConfigUpdater.config_redirect) + 4. 应用云手机覆盖值(_override) + 5. 绑定到当前任务(bind(task)) + + 属性访问: + 配置路径格式为 `Task.Group.Argument`,通过 `__getattr__` 映射为 + `self.Group_Argument`(下划线分隔),如 `self.Research_PresetFilter`。 + + 属性修改: + 通过 `__setattr__` 拦截已绑定的属性,自动将修改写入配置文件。 + + Attributes: + stop_event (threading.Event | None): 停止事件,用于跨线程通知停止。 + bound (dict): 当前任务绑定的属性名到配置路径的映射。 + is_hoarding_task (bool): 是否为囤积任务(影响空闲行为)。 + """ stop_event: threading.Event = None bound = {} @@ -80,7 +130,7 @@ def __setattr__(self, key, value): super().__setattr__(key, value) def __init__(self, config_name, task=None): - logger.attr("Server", self.SERVER) + logger.attr("服务器", self.SERVER) # 读取 ./config/.json self.config_name = config_name # YAML 文件中的原始 JSON 数据 @@ -108,15 +158,15 @@ def __init__(self, config_name, task=None): if self.is_template_config: # 供开发工具使用 - logger.info("Using template config, which is read only") + logger.info("[配置] 使用模板配置,只读模式") self.auto_update = False self.task = name_to_function("template") elif not os.path.exists(filepath_config(config_name)): from module.config.utils import is_oobe_needed if is_oobe_needed(): logger.warning( - "No configuration files found. " - "Run 'python gui.py' to complete initial setup." + "[配置] 未找到配置文件。" + "请运行 'python gui.py' 完成初始设置。" ) self._disable_task_switch = False self.init_task(task) @@ -174,7 +224,7 @@ def bind(self, func, func_list=None): func_list.insert(0, "Alas") if "General" not in func_list: func_list.insert(0, "General") - logger.info(f"Bind task {func_list}") + logger.info(f"[配置] 绑定任务 {func_list}") # 绑定参数 visited = set() @@ -304,18 +354,18 @@ def get_next(self): if self.pending_task: AzurLaneConfig.is_hoarding_task = False - logger.info(f"Pending tasks: {[f.command for f in self.pending_task]}") + logger.info(f"[配置] 待处理任务: {[f.command for f in self.pending_task]}") task = self.pending_task[0] - logger.attr("Task", task) + logger.attr("任务", task) return task else: AzurLaneConfig.is_hoarding_task = True if self.waiting_task: - logger.info("No task pending") + logger.info("[配置] 没有待处理任务") task = copy.deepcopy(self.waiting_task[0]) task.next_run = (task.next_run + self.hoarding).replace(microsecond=0) - logger.attr("Task", task) + logger.attr("任务", task) return task else: logger.critical("[Config] 没有等待或待处理的任务") @@ -330,7 +380,7 @@ def save(self, mod_name='alas'): deep_set(self.data, keys=path, value=value) logger.info( - f"Save config {filepath_config(self.config_name, mod_name)}, {dict_to_kv(self.modified)}" + f"[配置] 保存配置 {filepath_config(self.config_name, mod_name)}, {dict_to_kv(self.modified)}" ) # 不要使用 self.modified = {},那会创建新对象。 self.modified.clear() @@ -485,12 +535,12 @@ def ensure_delta(delay): ) if task is None: task = self.task.command - logger.info(f"Delay task `{task}` to {run} ({kv})") + logger.info(f"[配置] 延迟任务 `{task}` 到 {run} ({kv})") self.modified[f'{task}.Scheduler.NextRun'] = run self.update() else: raise ScriptError( - "Missing argument in delay_next_run, should set at least one" + "[配置] delay_next_run 缺少参数,应至少设置一个" ) def opsi_task_delay( @@ -531,7 +581,7 @@ def delay_tasks(task_list, minutes): keys = f"{task}.Scheduler.NextRun" current = deep_get(self.data, keys=keys, default=DEFAULT_TIME) if current < next_run: - logger.info(f"Delay task `{task}` to {next_run} ({kv})") + logger.info(f"[配置-大世界] 延迟任务 `{task}` 到 {next_run} ({kv})") self.modified[keys] = next_run def is_submarine_call(task): @@ -602,7 +652,7 @@ def is_special_radar(task): elif get_os_reset_remain() > 0: delay_tasks(tasks, minutes=360) else: - logger.info("Just less than 1 day to OpSi reset, delay 2.5 hours") + logger.info("[配置-大世界] 距离大世界重置不足1天,延迟2.5小时") delay_tasks(tasks, minutes=150) if cl1_preserve: tasks = SelectedGrids( @@ -632,10 +682,10 @@ def task_call(self, task, force_call=True): bool: 是否成功调用。 """ if deep_get(self.data, keys=f"{task}.Scheduler.NextRun", default=None) is None: - raise ScriptError(f"Task to call: `{task}` does not exist in user config") + raise ScriptError(f"[配置] 要调用的任务: `{task}` 在用户配置中不存在") if force_call or self.is_task_enabled(task): - logger.info(f"Task call: {task}") + logger.info(f"[配置] 任务调用: {task}") self.modified[f"{task}.Scheduler.NextRun"] = current_time().replace( microsecond=0 ) @@ -644,7 +694,7 @@ def task_call(self, task, force_call=True): self.update() return True else: - logger.info(f"Task call: {task} (skipped because disabled by user)") + logger.info(f"[配置] 任务调用: {task} (因用户禁用而跳过)") return False @staticmethod @@ -679,10 +729,10 @@ def task_switched(self): self.load() new = self.get_next() if prev == new: - logger.info(f"Continue task `{new}`") + logger.info(f"[配置] 继续任务 `{new}`") return False else: - logger.info(f"Switch task `{prev}` to `{new}`") + logger.info(f"[配置] 切换任务 `{prev}` 到 `{new}`") return True def check_task_switch(self, message=""): @@ -693,7 +743,7 @@ def check_task_switch(self, message=""): """ # 如果设置了禁用任务切换标志,则跳过检查 if getattr(self, '_disable_task_switch', False): - logger.info('Task switch check disabled temporarily') + logger.info('[配置] 任务切换检查已临时禁用') return if self.task_switched(): diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 9f7bea819..10278f04d 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -1,3 +1,9 @@ +"""自动生成的配置类。 + +由 config_updater.py 自动生成,包含所有配置项的默认值定义。 +提供 IDE 自动补全支持,不应手动修改此文件。 +""" + # 此文件是配置系统的更新器。 # 负责读取配置定义、生成 config_generated.py 以及处理配置的版本迁移、i18n 生成等核心管理任务。 import datetime diff --git a/module/config/config_manual.py b/module/config/config_manual.py index 4bac69091..78820be55 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -1,3 +1,15 @@ +"""手动配置定义模块。 + +定义非自动生成的硬编码配置项,包括: +- 服务器信息和资源文件路径 +- UI 按钮的服务器特定偏移量 +- 任务调度的默认优先级逻辑 +- 各功能模块的配置属性访问器 + +此文件中的配置项需要手动维护,不随 config_updater.py 自动更新。 +配置属性通过 `@property` 装饰器暴露,供 AzurLaneConfig 通过多重继承访问。 +""" + try: from pywebio.io_ctrl import Output except ImportError: @@ -21,6 +33,16 @@ def __new__(cls, *args, **kwargs): class ManualConfig: + """手动配置基类。 + + 提供 AzurLaneConfig 中不通过代码生成器创建的配置属性。 + 这些属性包括: + - 服务器标识(SERVER) + - 任务调度优先级(_DEFAULT_SCHEDULER_PRIORITY) + - 各功能模块的配置访问器(如 Research_PresetFilter、Fleet_FleetOrder 等) + + 通过多重继承被 AzurLaneConfig 组合使用。 + """ if TYPE_CHECKING: def cross_get(self, keys: list[str], default: Any = None) -> Any: ... YukikazeTaskManager_TaskPriorityAdjustment: str | None diff --git a/module/config/config_updater.py b/module/config/config_updater.py index feaeb12aa..4f0e2197e 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -1,3 +1,31 @@ +"""配置系统更新器。 + +配置系统的核心引擎,负责: +- 读取 YAML 配置定义文件(task.yaml、argument.yaml、override.yaml、default.yaml) +- 生成 Python 配置类(config_generated.py) +- 生成参数定义文件(args.json、menu.json) +- 生成国际化文件(i18n/*.json) +- 生成配置模板(template.json) +- 处理配置版本迁移和重定向 +- 管理活动/关卡数据的更新 + +配置生成管道: + task.yaml + argument.yaml + override.yaml + default.yaml + gui.yaml + → args.json(合并后的完整参数定义) + → menu.json(菜单结构) + → config_generated.py(Python 配置类) + → template.json(配置模板) + → i18n/*.json(五种语言翻译文件) + +通过命令行调用: + uv run -m module.config.config_updater + +主要类: +- ConfigUpdater: 配置更新和生成的基类 +- Event: 活动数据解析类 +- CampaignEvent: 战役活动配置管理 +""" + import re import typing as t from copy import deepcopy @@ -13,6 +41,7 @@ from module.config.utils import * from module.config.redirect_utils.utils import * +# config_generated.py 的头部模板 CONFIG_IMPORT = ''' # 此文件是配置系统的更新器。 # 负责读取配置定义、生成 config_generated.py 以及处理配置的版本迁移、i18n 生成等核心管理任务。 @@ -44,6 +73,20 @@ class GeneratedConfig: class Event: + """活动数据解析类。 + + 从 campaign/Readme.md 中解析活动信息,包含: + - date: 活动日期 + - directory: 活动目录名(如 'event_20230101_cn') + - name: 活动英文名 + - cn/en/jp/tw: 各服务器的活动名称 + + 属性: + is_war_archives (bool): 是否为作战档案活动 + is_raid (bool): 是否为突袭活动 + is_coalition (bool): 是否为联动活动 + """ + def __init__(self, text): self.date, self.directory, self.name, self.cn, self.en, self.jp, self.tw \ = [x.strip() for x in text.strip('| \n').split('|')] diff --git a/module/config/deep.py b/module/config/deep.py index 8ea7b24a2..b9b248479 100644 --- a/module/config/deep.py +++ b/module/config/deep.py @@ -1,3 +1,9 @@ +"""嵌套字典高性能访问模块。 + +提供 deep_get、deep_set、deep_pop、deep_iter 等函数, +用于高性能地访问和操作嵌套字典/列表结构的配置数据。 +""" + from collections import deque # deep_* 系列函数用于访问嵌套字典。 diff --git a/module/config/env.py b/module/config/env.py index c4310aa4f..07c64cbbb 100644 --- a/module/config/env.py +++ b/module/config/env.py @@ -1,3 +1,10 @@ +"""运行环境检测模块。 + +检测 AzurPilot 是否运行在云手机环境中。 +云手机环境需要特殊处理某些配置项(如设备连接、ADB 路径等)。 +""" + import os +# 检测云手机环境变量 IS_ON_PHONE_CLOUD = os.environ.get("cloudphone", "") == "cloudphone" diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 03adf7588..98d3d5292 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -111,7 +111,7 @@ "help": "Farm affinity by having ships sunk in Coalition events. Recommend sortie with multiple fleets; the first fleet sinks. Refer to tutorials for details." }, "MaritimeEscort": { - "name": "Merchant Ship Escort", + "name": "Trade Convoy Plus", "help": "" }, "EventShop": { @@ -155,11 +155,11 @@ "help": "" }, "Tactical": { - "name": "Tactical Academy", + "name": "Tactical Class Plus", "help": "" }, "Research": { - "name": "Research", + "name": "Research Lab Plus", "help": "" }, "Dorm": { @@ -191,7 +191,7 @@ "help": "" }, "Exercise": { - "name": "Exercise", + "name": "Exercises Plus", "help": "" }, "ShopFrequent": { @@ -403,7 +403,16 @@ "help": "Provides an entry point for manually restarting the emulator." }, "BattlePass": { - "name": "Battle Pass Plus" + "name": "Battle Pass Plus", + "help": "" + }, + "CoreShop": { + "name": "Core Shop Plus", + "help": "" + }, + "MeowfficerTrain": { + "name": "Meowfficer Training Plus", + "help": "" } }, "Scheduler": { @@ -1001,8 +1010,8 @@ }, "OneClickRetire": { "_info": { - "name": "One-click retirement settings", - "help": "Only takes effect when \"Retirement Mode\" is set to \"One-click retirement\"" + "name": "Quick Retire Settings", + "help": "Only takes effect when \"Retirement Mode\" is set to \"Quick Retire\"" }, "KeepLimitBreak": { "name": "Keep the star-filled ship with the same name", @@ -1845,11 +1854,11 @@ }, "MaritimeEscort": { "_info": { - "name": "Merchant Escort", + "name": "Trade Convoy", "help": "Enter and immediately retreat, can earn about 70% of the rewards" }, "Enable": { - "name": "Enable Merchant Escort", + "name": "Enable Trade Convoy", "help": "" } }, @@ -1952,7 +1961,7 @@ }, "Tactical": { "_info": { - "name": "Tactics Academy", + "name": "Tactical Class", "help": "" }, "TacticalFilter": { @@ -2021,7 +2030,7 @@ }, "Research": { "_info": { - "name": "Research", + "name": "Research Lab", "help": "When filters work with other settings, AzurPilot looks for items that satisfy all user settings simultaneously.\nFor example, if you set the research filter to `H1 > D2.5 > shortest` (including research that consumes cubes), but also set it not to allow cube consumption, the final result is that AzurPilot will not select research that consumes cubes. When you suddenly want to use cubes, you only need to enable cube consumption without modifying the filter, which is more convenient." }, "UseCube": { @@ -2509,7 +2518,7 @@ }, "CoreShop": { "_info": { - "name": "Core Data Exchange", + "name": "Core Shop", "help": "" }, "Enable": { @@ -2743,7 +2752,7 @@ "help": "After getting S rank on the daily stage, sweep is unlocked. If not unlocked, it will automatically fall back to the traditional sortie." }, "EscortMission": { - "name": "Escort Mission (Parts, Torpedo)", + "name": "Trade Convoy (Parts, Torpedo)", "help": "", "skip": "Skip", "first": "First", @@ -2751,7 +2760,7 @@ "third": "Third" }, "EscortMissionFleet": { - "name": "Escort Mission sortie fleet", + "name": "Trade Convoy sortie fleet", "help": "", "1": "1", "2": "2", @@ -2875,7 +2884,7 @@ }, "Exercise": { "_info": { - "name": "Exercise", + "name": "Exercises", "help": "Run Exercise, automatically retreat when HP is too low" }, "DelayUntilHoursBeforeNextUpdate": { @@ -3419,7 +3428,7 @@ "help": "Operation Siren smart scheduling function, used to coordinate switching between Corrosion 1 Leveling and yellow coin replenishment tasks.\nOnce enabled, it will automatically switch between different tasks and send push notifications based on coins and Action Points.\n\nSome safety restrictions of the original AzurPilot have been lifted, please set parameters carefully" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "Reserve X Operation Supply Tokens", + "name": "Reserve X Operation Supply Coins", "help": "When enabled, will use the 'Obtain X Combat Supply Vouchers' configuration below. When disabled, will use the Corrosion 1 Leveling yellow coin retention configuration.\nWhen disabled, Action Points are used for control.\nWhen enabled, yellow coins are used for control.", "True": "Enabled", "False": "Disabled" @@ -3433,7 +3442,7 @@ "help": "Stop when Action Points are lower than X, and automatically open AP boxes. X includes AP in boxes. It is recommended to preserve 1000 AP for Corrosion 1 Leveling." }, "OperationCoinsReturnThreshold": { - "name": "Return When Supply Tokens Below X", + "name": "Return When Supply Coins Below X", "help": "During the yellow coin acquisition task, obtain (increase) X Combat Supply Vouchers in total, then return to Corrosion 1 Leveling." }, "EnableMeowfficerFarming": { @@ -6344,4 +6353,4 @@ "ButtonCreate": "Create & Finish" } } -} \ No newline at end of file +} diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index cd105eb6c..0a914d00f 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -111,7 +111,7 @@ "help": "連合イベントで艦船が撃沈された後に好感度を周回します。複数艦隊での出撃を推奨。第1艦隊で沈めます。関連チュートリアルをご確認ください。" }, "MaritimeEscort": { - "name": "商船の護衛", + "name": "海上護衛Plus", "help": "" }, "EventShop": { @@ -151,23 +151,23 @@ "help": "" }, "Commission": { - "name": "委託", + "name": "軍事委託Plus", "help": "" }, "Tactical": { - "name": "戦術学院", + "name": "戦術教室Plus", "help": "" }, "Research": { - "name": "科研Plus", + "name": "科学研究室Plus", "help": "" }, "Dorm": { - "name": "後宅", + "name": "寮舎Plus", "help": "" }, "Meowfficer": { - "name": "指揮ニャ", + "name": "オフニャPlus", "help": "" }, "Guild": { @@ -191,7 +191,7 @@ "help": "" }, "Exercise": { - "name": "演習", + "name": "軍事演習Plus", "help": "" }, "ShopFrequent": { @@ -288,7 +288,7 @@ }, "OpsiCrossMonth": { "name": "月跨ぎデイリー", - "help": " AzurPilotはセイレーン作戦の月跨ぎリセットの10分前にセイレーン作戦に入り、リセットを待ちますが退出しません。その後、新しい日のセイレーン作戦デイリー、隠秘海域、深淵海域と指揮ニャファームを完了して、追加の報酬を獲得します。セイレーン作戦デイリーを実行する場合は「セイレーン作戦-デイリー」タスク設定に従い、その他も同様です。\n 重要:AzurPilotが月跨ぎを待っている間は、ゲームを操作しないでください。" + "help": " AzurPilotはセイレーン作戦の月跨ぎリセットの10分前にセイレーン作戦に入り、リセットを待ちますが退出しません。その後、新しい日のセイレーン作戦デイリー、隠秘海域、深淵海域とオフニャファームを完了して、追加の報酬を獲得します。セイレーン作戦デイリーを実行する場合は「セイレーン作戦-デイリー」タスク設定に従い、その他も同様です。\n 重要:AzurPilotが月跨ぎを待っている間は、ゲームを操作しないでください。" }, "OpsiSimulator": { "name": "セイレーン作戦シミュレーター Alpha", @@ -401,6 +401,18 @@ "EmulatorManager": { "name": "エミュレータマネージャー", "help": "エミュレータを手動で再起動するためのエントリポイントを提供します。" + }, + "MeowfficerTrain": { + "name": "オフニャ訓練Plus", + "help": "" + }, + "BattlePass": { + "name": "シーズンパスPlus", + "help": "" + }, + "CoreShop": { + "name": "コアショップPlus", + "help": "" } }, "Scheduler": { @@ -881,13 +893,13 @@ "save_and_upload": "保存してアップロードする" }, "MeowfficerBuy": { - "name": "指揮ニャ購入スクリーンショット", + "name": "オフニャ購入スクリーンショット", "help": "", "do_not": "アクションなし", "save": "保存" }, "MeowfficerTalent": { - "name": "指揮ニャ天賦スクリーンショット", + "name": "オフニャ天賦スクリーンショット", "help": "", "do_not": "アクションなし", "save": "保存", @@ -973,9 +985,9 @@ "FleetRecover": { "name": "コンディション回復", "help": "", - "not_in_dormitory": "後宅に配置していない(20/h)", - "dormitory_floor_1": "後宅1階(40/h)", - "dormitory_floor_2": "後宅2階(50/h)" + "not_in_dormitory": "寮舎に配置していない(20/h)", + "dormitory_floor_1": "寮舎1階(40/h)", + "dormitory_floor_2": "寮舎2階(50/h)" }, "FleetOath": { "name": "全メンバーがケッコン済み (+10/h)", @@ -1474,9 +1486,9 @@ "Fleet1Recover": { "name": "第一艦隊コンディション回復", "help": "", - "not_in_dormitory": "後宅に配置していない(20/h)", - "dormitory_floor_1": "後宅1階(40/h)", - "dormitory_floor_2": "後宅2階(50/h)" + "not_in_dormitory": "寮舎に配置していない(20/h)", + "dormitory_floor_1": "寮舎1階(40/h)", + "dormitory_floor_2": "寮舎2階(50/h)" }, "Fleet1Oath": { "name": "第一艦隊全員ケッコン済み (+10/h)", @@ -1505,9 +1517,9 @@ "Fleet2Recover": { "name": "第二艦隊コンディション回復", "help": "", - "not_in_dormitory": "後宅に配置していない(20/h)", - "dormitory_floor_1": "後宅1階(40/h)", - "dormitory_floor_2": "後宅2階(50/h)" + "not_in_dormitory": "寮舎に配置していない(20/h)", + "dormitory_floor_1": "寮舎1階(40/h)", + "dormitory_floor_2": "寮舎2階(50/h)" }, "Fleet2Oath": { "name": "第二艦隊全員ケッコン済み (+10/h)", @@ -1842,11 +1854,11 @@ }, "MaritimeEscort": { "_info": { - "name": "商船の護衛", - "help": "入って撤退すれば、報酬の約70%を獲得できます。" + "name": "海上護衛", + "help": "入ってすぐに撤退すれば、報酬の約70%を獲得できます" }, "Enable": { - "name": "商船の護衛を有効にする", + "name": "海上護衛を有効にする", "help": "" } }, @@ -1913,7 +1925,7 @@ }, "Commission": { "_info": { - "name": "委託", + "name": "軍事委託", "help": "" }, "PresetFilter": { @@ -1949,7 +1961,7 @@ }, "Tactical": { "_info": { - "name": "戦術学院", + "name": "戦術教室", "help": "" }, "TacticalFilter": { @@ -2018,7 +2030,7 @@ }, "Research": { "_info": { - "name": "科研", + "name": "科学研究室", "help": "フィルターが他の設定と連動する場合、AzurPilotはすべてのユーザー設定を同時に満たす項目を検索します。\n例えば、科研フィルターを `H1 > D2.5 > shortest`(キューブを消費する科研を含む)に設定し、同時にキューブの使用を許可しないように設定した場合、最終結果はAzurPilotがキューブを消費する科研を選択しないことになります。急にキューブの使用を切り替えたい場合、フィルターを変更せずにキューブの使用を有効にするだけで済むので便利です。" }, "UseCube": { @@ -2094,7 +2106,7 @@ }, "Dorm": { "_info": { - "name": "後宅", + "name": "寮舎", "help": "" }, "Collect": { @@ -2117,7 +2129,7 @@ "BuyFurniture": { "_info": { "name": "イベント家具設定", - "help": "イベント中に後宅家具ショップから期間限定家具を購入する" + "help": "イベント中に寮舎家具ショップから期間限定家具を購入する" }, "Enable": { "name": "イベント家具を購入する", @@ -2136,40 +2148,40 @@ }, "Meowfficer": { "_info": { - "name": "指揮ニャ", + "name": "オフニャ", "help": "" }, "BuyAmount": { - "name": "毎日X個のネコハコを購入する", + "name": "毎日オフニャボックスをX個購入", "help": "毎日最初のネコハコは無料です" }, "FortChoreMeowfficer": { - "name": "ニャコハウス互動", + "name": "オフニャハウス互動", "help": "掃除、給食、ネコじゃらし" } }, "MeowfficerTrain": { "_info": { - "name": "指揮ニャ訓練", + "name": "オフニャ訓練", "help": "" }, "Enable": { - "name": "指揮ニャ訓練を開始する", + "name": "オフニャ訓練を開始する", "help": "" }, "Mode": { "name": "訓練モードを選択", - "help": "連続訓練は2.5~3.5時間ごとに指揮ニャを受け取ります\n毎日1匹受け取りは毎日1匹受け取り、日曜日に全部受け取り、強化してキューを補充し、週間任務を完了します。", + "help": "連続訓練は2.5~3.5時間ごとにオフニャを受け取ります\n毎日1匹受け取りは毎日1匹受け取り、日曜日に全部受け取り、強化してキューを補充し、週間任務を完了します。", "seamlessly": "連続訓練", "once_a_day": "毎日1匹受け取り" }, "RetainTalentedGold": { "name": "特殊天賦持ちの金ネコをロックする", - "help": "指揮ニャ受け取り時に特殊天賦のネコをロックします。ロックされたネコは強化に使用されません。ユーザーは手動で確認し不要なネコのロックを解除する必要があります。" + "help": "オフニャ受け取り時に特殊天賦のネコをロックします。ロックされたネコは強化に使用されません。ユーザーは手動で確認し不要なネコのロックを解除する必要があります。" }, "RetainTalentedPurple": { "name": "特殊天賦持ちの紫ネコをロックする", - "help": "指揮ニャ受け取り時に特殊天賦のネコをロックします。ロックされたネコは強化に使用されません。ユーザーは手動で確認し不要なネコのロックを解除する必要があります。" + "help": "オフニャ受け取り時に特殊天賦のネコをロックします。ロックされたネコは強化に使用されません。ユーザーは手動で確認し不要なネコのロックを解除する必要があります。" }, "EnhanceIndex": { "name": "特殊天賦なしのネコでX番目のネコを強化する", @@ -2506,7 +2518,7 @@ }, "CoreShop": { "_info": { - "name": "コア交換", + "name": "コアショップ", "help": "" }, "Enable": { @@ -2520,7 +2532,7 @@ }, "ShipyardDr": { "_info": { - "name": "色彩科研設計図購入設定", + "name": "彩色科研設計図購入設定", "help": "色彩科研設計図を毎日購入します。色彩科研と金科研の設計図は割引カウントが独立しており、毎日それぞれ2枚無料で入手できます。" }, "ResearchSeries": { @@ -2740,7 +2752,7 @@ "help": "デイリーレベルSに勝利すると、スイープがロック解除されます。ロックが解除されていない場合は、自動的に従来の攻撃に戻ります。" }, "EscortMission": { - "name": "商船護衛(パーツ本、魚雷本)", + "name": "海上護衛(パーツ本、魚雷本)", "help": "", "skip": "スキップ", "first": "1番目", @@ -2748,7 +2760,7 @@ "third": "3番目" }, "EscortMissionFleet": { - "name": "商船護衛出撃艦隊", + "name": "海上護衛出撃艦隊", "help": "", "1": "1", "2": "2", @@ -2872,7 +2884,7 @@ }, "Exercise": { "_info": { - "name": "演習", + "name": "軍事演習", "help": "演習を実行し、HPが低すぎる場合は自動的に撤退します" }, "DelayUntilHoursBeforeNextUpdate": { @@ -2967,11 +2979,11 @@ "name": "行動力をX回購入", "help": "行動力を購入する回数。1回目の購入で燃料1000、行動力100を消費します", "0": "購入しない", - "1": "1回(燃料1000、作戦記録100)", - "2": "2回(燃料2000、作戦記録200)", - "3": "3回(燃料4000、作戦記録400)", - "4": "4回(燃料6000、作戦記録600)", - "5": "5回(燃料10000、作戦記録1000)" + "1": "1回(燃料1000、行動力100)", + "2": "2回(燃料2000、行動力200)", + "3": "3回(燃料4000、行動力400)", + "4": "4回(燃料6000、行動力600)", + "5": "5回(燃料10000、行動力1000)" }, "OilLimit": { "name": "行動力購入時に燃料Xを保留", @@ -3101,8 +3113,8 @@ "help": "自動的に更新される値。進行状況をリセットするには0を入力します。リセット後は開放済み海域は自動でスキップされます。\n海域ID、中国サーバー/グローバルサーバー/日本サーバー/台湾サーバーの海域名をサポート(例:「51」「NA Ocean SE Sector E」など)" }, "AllowHazard1Leveling": { - "name": "探索中の侵食1レベリングを許可", - "help": "有効にすると、侵食1レベリング任務を延期しなくなります。行動力が足りなくなってエリア開拓ができなくなる恐れがあるため、慎重に使用してください。" + "name": "探索中の侵蝕1レベリングを許可", + "help": "有効にすると、侵蝕1レベリング任務を延期しなくなります。行動力が足りなくなってエリア開拓ができなくなる恐れがあるため、慎重に使用してください。" }, "ExploreProgress": { "name": "月間開拓進捗", @@ -3179,7 +3191,7 @@ }, "SkipHazard2Obscure": { "name": "レベル2座標をクリアしない", - "help": "オンにすると、侵食レベル2の隠秘海域を攻撃しません。" + "help": "オンにすると、侵蝕レベル2の隠秘海域を攻撃しません。" }, "ForceRun": { "name": "実行間隔に関係なく強制実行", @@ -3237,15 +3249,15 @@ "OpsiMeowfficerFarming": { "_info": { "name": "短猫相接", - "help": "時計回りに指定侵食レベルの海域を出撃します。この機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦メインをクリアし、シミュレーション+セイレーン試験場を完了\n- 每月開拓+タスクを有効にするか戦役情報記録儀(5000燃料アイテム)を使用" + "help": "時計回りに指定侵蝕レベルの海域を出撃します。この機能を使用する前に以下の条件を満たす必要があります:\n- セイレーン作戦メインをクリアし、シミュレーション+セイレーン試験場を完了\n- 每月開拓+タスクを有効にするか戦役情報記録儀(5000燃料アイテム)を使用" }, "ActionPointPreserve": { "name": "行動力をXポイント保留", - "help": "行動力がX以下になると停止し、行動力ボックスを自動で開きます。\nこの値はセイレーン作戦リセット3日前に自動的に200ポイントに減ります。侵食1レベリングが有効な場合は1000ポイントに変更され、最終日には自動的に0ポイントに減ります。" + "help": "行動力がX以下になると停止し、行動力ボックスを自動で開きます。\nこの値はセイレーン作戦リセット3日前に自動的に200ポイントに減ります。侵蝕1レベリングが有効な場合は1000ポイントに変更され、最終日には自動的に0ポイントに減ります。" }, "HazardLevel": { - "name": "侵食レベル", - "help": "指定した侵食レベルの海域を時計回りに攻撃します。\n指揮ニャポイント/行動力の比率が高い5または3を選択するのがおすすめです。", + "name": "侵蝕レベル", + "help": "指定した侵蝕レベルの海域を時計回りに攻撃します。\nオフニャポイント/行動力の比率が高い5または3を選択するのがおすすめです。", "2": "2", "3": "3", "4": "4", @@ -3255,7 +3267,7 @@ }, "TargetZone": { "name": "指定海域", - "help": "指定した海域のみを出撃します。海域IDまたはCN/EN/JP/TWの海域名に対応します。0を入力すると侵食レベルによるデフォルト探索に戻ります。\n「指定海域での作戦計画」を有効にすると、42,55,45、42, 55, 45、42,55,45 のように複数海域を入力し、入力順に循環できます。" + "help": "指定した海域のみを出撃します。海域IDまたはCN/EN/JP/TWの海域名に対応します。0を入力すると侵蝕レベルによるデフォルト探索に戻ります。\n「指定海域での作戦計画」を有効にすると、42,55,45、42, 55, 45、42,55,45 のように複数海域を入力し、入力順に循環できます。" }, "StayInZone": { "name": "指定海域での作戦計画を立てる", @@ -3286,7 +3298,7 @@ }, "OpsiHazard1Leveling": { "_info": { - "name": "侵食 1 レベリング", + "name": "侵蝕 1 レベリング", "help": "戦闘供給トークンを消費して、大量の経験と少量の行動力を獲得します\n最も高い平均時給を得るには、事前装填済みの航空母艦を 1 ~ 2 隻持参することをお勧めします。\n\n悲しいかな、オリジナルのいくつかの絶対確実な制限が解除されました。パラメータを慎重に設定してください。" }, "TargetZone": { @@ -3320,11 +3332,11 @@ }, "RecordNonCL1AP": { "name": "非侵蝕1由来の行動力を統計", - "help": "オンにすると、すべてのソース(明石店での購入など)のスタミナ獲得データがカウントされます。オフにすると侵食1レベリングタスクのみ体力データがカウントされ、他のタスクや手動操作で得た体力は無視されます。" + "help": "オンにすると、すべてのソース(明石店での購入など)のスタミナ獲得データがカウントされます。オフにすると侵蝕1レベリングタスクのみ体力データがカウントされ、他のタスクや手動操作で得た体力は無視されます。" }, "RecordSeaMiles": { "name": "海里数記録", - "help": "侵食1レベリングサイクルごとに海里数を統計に記録します", + "help": "侵蝕1レベリングサイクルごとに海里数を統計に記録します", "True": "有効", "False": "無効" } @@ -3416,13 +3428,13 @@ "help": "セイレーン作戦のインテリジェントスケジューリング機能は、侵蝕1レベリングとイエローコイン補充タスク間の切り替えを調整します。\n有効にすると、さまざまなタスクが自動的に切り替わり、ゴールド コインとモビリティに基づいてプッシュ通知が送信されます。\n\n悲しいかな、オリジナルのいくつかの絶対確実な制限が解除されました。パラメータを慎重に設定してください。" }, "UseSmartSchedulingOperationCoinsPreserve": { - "name": "作戦補給トークンをX個保留", - "help": "有効にすると、以下に設定された戦闘補給券をX個入手する構成が使用されます。無効にすると、侵食 1 レベリングと黄色のコイン保持構成が使用されます。\n閉じたときにモビリティ制御が使用されます\n開いた後、黄色のコインを使用して制御します", + "name": "作戦補給コインをX個保留", + "help": "有効にすると、以下に設定された戦闘補給券をX個入手する構成が使用されます。無効にすると、侵蝕 1 レベリングと黄色のコイン保持構成が使用されます。\n閉じたときにモビリティ制御が使用されます\n開いた後、黄色のコインを使用して制御します", "True": "有効", "False": "無効" }, "OperationCoinsPreserve": { - "name": "侵食 1 レベリング、X 戦闘供給券を維持", + "name": "侵蝕 1 レベリング、X 戦闘供給券を維持", "help": "戦闘補給バウチャーが X より低い場合は、黄色のコイン補充タスクを実行します。これを変更することはお勧めできません。" }, "ActionPointPreserve": { @@ -3430,8 +3442,8 @@ "help": "モビリティがX以下になると停止し、モビリティボックスが自動的に開きます。" }, "OperationCoinsReturnThreshold": { - "name": "作戦補給トークンがX以下で帰投", - "help": "黄色のコイン獲得ミッション中に、戦闘補給券を合計 X 枚獲得 (増加) し、レベルを上げるために侵食 1 に戻ります。" + "name": "作戦補給コインがX以下で帰投", + "help": "黄色のコイン獲得ミッション中に、戦闘補給券を合計 X 枚獲得 (増加) し、レベルを上げるために侵蝕 1 に戻ります。" }, "EnableMeowfficerFarming": { "name": "短猫相接を有効にする", @@ -3469,7 +3481,7 @@ "OpsiPreventActionPointOverflow": { "_info": { "name": "行動力溢れ防止", - "help": "現在行動力が上限に達した時、セイレーン作戦任務を実行して行動力を消費します\nスマートスケジューリングを選択した場合、行動力と作戦補給コインがどちらも不足して実行可能な任務がない時は、指揮ニャ周回を実行して現在行動力を消費します" + "help": "現在行動力が上限に達した時、セイレーン作戦任務を実行して行動力を消費します\nスマートスケジューリングを選択した場合、行動力と作戦補給コインがどちらも不足して実行可能な任務がない時は、オフニャ周回を実行して現在行動力を消費します" }, "Task": { "name": "実行する任務", @@ -5802,8 +5814,8 @@ "help": "0を入力すると自動取得。変更は推奨しません。" }, "MeowHazardLevel": { - "name": "指揮ニャ侵蝕レベル", - "help": "指揮ニャタスクの実行レベルを選択(レベル3またはレベル5)。", + "name": "オフニャ侵蝕レベル", + "help": "オフニャタスクの実行レベルを選択(レベル3またはレベル5)。", "level3": "level3", "level5": "level5" }, @@ -5821,11 +5833,11 @@ }, "Meow3Coin": { "name": "短3コイン期待値", - "help": "侵蝕3指揮ニャタスクあたりの平均コイン。推奨:600" + "help": "侵蝕3オフニャタスクあたりの平均コイン。推奨:600" }, "Meow5Coin": { "name": "短5コイン期待値", - "help": "侵蝕5指揮ニャタスクあたりの平均コイン。推奨:1700" + "help": "侵蝕5オフニャタスクあたりの平均コイン。推奨:1700" }, "AkashiProbability": { "name": "明石遭遇確率", @@ -5837,11 +5849,11 @@ }, "Meow3Time": { "name": "短3単一サイクル時間", - "help": "単位:秒。侵蝕3指揮ニャタスクの平均時間。0で履歴統計から取得。" + "help": "単位:秒。侵蝕3オフニャタスクの平均時間。0で履歴統計から取得。" }, "Meow5Time": { "name": "短5単一サイクル時間", - "help": "単位:秒。侵蝕5指揮ニャタスクの平均時間。0で履歴統計から取得。" + "help": "単位:秒。侵蝕5オフニャタスクの平均時間。0で履歴統計から取得。" }, "Deterministic": { "name": "ランダム性を無効化", @@ -6291,7 +6303,7 @@ "OperationCoinsPreserve": "作戦コイン保留", "ActionPointPreserve": "行動力保留", "OperationCoinsReturnThreshold": "作戦コイン戻り閾値", - "EnableMeowfficerFarming": "指揮ニャー収集を有効", + "EnableMeowfficerFarming": "オフニャ収集を有効", "EnableObscure": "隠秘海域を有効", "EnableAbyssal": "深淵海域を有効", "EnableStronghold": "セイレーン要塞を有効", @@ -6341,4 +6353,4 @@ "ButtonCreate": "作成して完了" } } -} \ No newline at end of file +} diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 225710c92..be8b1b568 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -111,7 +111,7 @@ "help": "联盟活动中舰船被击沉后刷取好感度,建议多队出击,第一队就沉,建议看相关教程" }, "MaritimeEscort": { - "name": "商船护航", + "name": "商路护航", "help": "" }, "EventShop": { @@ -401,6 +401,18 @@ "EmulatorManager": { "name": "模拟器管理器", "help": "提供手动重启模拟器的入口" + }, + "BattlePass": { + "name": "大月卡Plus", + "help": "" + }, + "CoreShop": { + "name": "核心兑换Plus", + "help": "" + }, + "MeowfficerTrain": { + "name": "指挥喵训练Plus", + "help": "" } }, "Scheduler": { @@ -1842,11 +1854,11 @@ }, "MaritimeEscort": { "_info": { - "name": "商船护航", + "name": "商路护航", "help": "进一下就撤退,能拿约70%的奖励" }, "Enable": { - "name": "启用商船护航", + "name": "启用商路护航", "help": "" } }, @@ -2740,7 +2752,7 @@ "help": "每日关卡S胜后,解锁扫荡,未解锁时,自动回退至传统出击" }, "EscortMission": { - "name": "商船护送(部件本,鱼雷本)", + "name": "商路护航(部件本,鱼雷本)", "help": "", "skip": "不打", "first": "第一个", @@ -2748,7 +2760,7 @@ "third": "第三个" }, "EscortMissionFleet": { - "name": "商船护送出击舰队", + "name": "商路护航出击舰队", "help": "", "1": "1", "2": "2", @@ -6337,4 +6349,4 @@ "ButtonCreate": "创建并完成" } } -} \ No newline at end of file +} diff --git a/module/config/i18n/zh-MIAO.json b/module/config/i18n/zh-MIAO.json index fa9c19ca2..4499b40df 100644 --- a/module/config/i18n/zh-MIAO.json +++ b/module/config/i18n/zh-MIAO.json @@ -111,7 +111,7 @@ "help": "Task.CoalitionScuttle.help" }, "MaritimeEscort": { - "name": "商船护航护送喵", + "name": "商路护航喵", "help": "保护商船,正义的雪风大人登场!ᕦ(ò_óˇ)ᕤ" }, "EventShop": { @@ -401,6 +401,18 @@ "EmulatorManager": { "name": "模拟器管理器", "help": "提供手动重启模拟器的入口喵~ (=^・ω・^=)" + }, + "BattlePass": { + "name": "大月卡喵", + "help": "领取大月卡奖励喵~ (´・ω・`)" + }, + "CoreShop": { + "name": "核心兑换喵", + "help": "到核心商店兑换需要的物品喵~ (・ω・)" + }, + "MeowfficerTrain": { + "name": "指挥喵训练喵", + "help": "安排指挥喵训练,别让小猫偷懒喵~ (=^・ω・^=)" } }, "Scheduler": { @@ -1842,11 +1854,11 @@ }, "MaritimeEscort": { "_info": { - "name": "商船护航", + "name": "商路护航", "help": "进去就撤退,约70%奖励喵。 (´・ω・`)" }, "Enable": { - "name": "启用商船护航喵", + "name": "启用商路护航喵", "help": "" } }, @@ -2740,7 +2752,7 @@ "help": "每日S胜解锁扫荡,未解锁回退传统出击喵。 (´・ω・`)" }, "EscortMission": { - "name": "商船护送", + "name": "商路护航", "help": "", "skip": "不打喵", "first": "第一个喵", @@ -2748,7 +2760,7 @@ "third": "第三个喵" }, "EscortMissionFleet": { - "name": "商船护送出击舰队", + "name": "商路护航出击舰队", "help": "", "1": "1喵", "2": "2喵", @@ -6337,4 +6349,4 @@ "ButtonCreate": "创建并完成喵!" } } -} \ No newline at end of file +} diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 4fbb395f7..0b40637f5 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -111,7 +111,7 @@ "help": "聯盟活動中艦船被擊沉後刷取好感度,建議多隊出擊,第一隊就沉,建議看相關教程" }, "MaritimeEscort": { - "name": "商船護航", + "name": "商路護航Plus", "help": "" }, "EventShop": { @@ -401,6 +401,18 @@ "EmulatorManager": { "name": "模擬器管理器", "help": "提供手動重啟模擬器的入口" + }, + "BattlePass": { + "name": "大月卡Plus", + "help": "" + }, + "CoreShop": { + "name": "核心兌換Plus", + "help": "" + }, + "MeowfficerTrain": { + "name": "指揮喵訓練Plus", + "help": "" } }, "Scheduler": { @@ -1842,11 +1854,11 @@ }, "MaritimeEscort": { "_info": { - "name": "商船護航", + "name": "商路護航", "help": "進一下就撤退,能拿約70%的獎勵" }, "Enable": { - "name": "啟用商船護航", + "name": "啟用商路護航", "help": "" } }, @@ -2740,7 +2752,7 @@ "help": "每日關卡S勝後,解鎖掃蕩,未解鎖時,自動回退至傳統出擊" }, "EscortMission": { - "name": "商船護送(部件本,魚雷本)", + "name": "商路護航(部件本,魚雷本)", "help": "", "skip": "不打", "first": "第一個", @@ -2748,7 +2760,7 @@ "third": "第三個" }, "EscortMissionFleet": { - "name": "商船護送出擊艦隊", + "name": "商路護航出擊艦隊", "help": "", "1": "1", "2": "2", @@ -6337,4 +6349,4 @@ "ButtonCreate": "建立並完成" } } -} \ No newline at end of file +} diff --git a/module/config/mcp_helper.py b/module/config/mcp_helper.py index fa84df9f7..aa4ff7865 100644 --- a/module/config/mcp_helper.py +++ b/module/config/mcp_helper.py @@ -1,9 +1,37 @@ +"""MCP 配置辅助模块。 + +为 MCP (Model Context Protocol) 服务器提供配置数据的结构化访问。 +MCP 服务器通过此模块获取任务列表、任务详情和配置信息, +供外部 AI 助手查询和修改 AzurPilot 的配置。 + +主要功能: +- get_tasks(): 获取所有可调度任务的名称列表 +- get_task_details(): 获取指定任务的详细参数定义(含国际化) +- get_dashboard_resources(): 获取仪表盘资源列表 + +配置数据来源: +- args.json: 合并后的完整参数定义 +- i18n/{lang}.json: 国际化翻译文件 +""" + import json import os from typing import Dict, Any, List, Optional from module.config.utils import read_file, filepath_args, filepath_i18n + class McpConfigHelper: + """MCP 配置数据访问助手。 + + 从 args.json 和 i18n 文件中读取配置元数据, + 提供结构化的任务和参数信息供 MCP 服务器使用。 + + Attributes: + lang (str): 当前语言代码,如 'zh-CN'、'en-US'。 + args_data (dict): 从 args.json 加载的参数定义数据。 + i18n_data (dict): 从 i18n 文件加载的国际化数据。 + """ + def __init__(self, lang="zh-CN"): self.lang = lang self.args_data = read_file(filepath_args("args")) diff --git a/module/config/redirect_utils/os_handler.py b/module/config/redirect_utils/os_handler.py index 0aa6d0a20..1490f50c6 100644 --- a/module/config/redirect_utils/os_handler.py +++ b/module/config/redirect_utils/os_handler.py @@ -1,3 +1,10 @@ +"""大世界配置重定向工具。 + +包含 action_point_redirect 等函数,用于将旧版大世界处理器的布尔配置值 +迁移转换为新版的数值格式。 +""" + + def action_point_redirect(value): """ redirect attr about action point diff --git a/module/config/redirect_utils/shop_filter.py b/module/config/redirect_utils/shop_filter.py index 8769865c1..7c700309a 100644 --- a/module/config/redirect_utils/shop_filter.py +++ b/module/config/redirect_utils/shop_filter.py @@ -1,3 +1,9 @@ +"""商店过滤器配置重定向工具。 + +提供 bp_redirect 等函数,用于将商店过滤器的旧版科研蓝图格式 +(如 PRBP、PROdinBP)迁移转换为新版系列格式(如 PRS1、PROdinS3)。 +""" + import re FILTER_REGEX_SERIES = re.compile( diff --git a/module/config/redirect_utils/utils.py b/module/config/redirect_utils/utils.py index 74adf1d01..58d6a6deb 100644 --- a/module/config/redirect_utils/utils.py +++ b/module/config/redirect_utils/utils.py @@ -1,3 +1,18 @@ +"""配置重定向工具函数集。 + +提供配置版本升级时的值转换函数。 +当配置 schema 发生变更时(如选项名称修改、值格式调整), +这些函数负责将旧格式的配置值转换为新格式。 + +重定向函数在 ConfigUpdater.config_redirect() 中被调用, +确保用户升级后无需手动修改配置文件。 + +常见的重定向场景: +- 选项名称变更(如 'auto' → 'default') +- 值格式调整(如布尔值 → 枚举值) +- 服务器名称规范化 +""" + from module.config.server import to_server diff --git a/module/config/task_priority.py b/module/config/task_priority.py index a81d320ea..e8775daec 100644 --- a/module/config/task_priority.py +++ b/module/config/task_priority.py @@ -1,3 +1,9 @@ +"""任务优先级解析模块。 + +提供 parse_task_priority 函数,解析用户配置中的任务优先级文本, +支持 Unicode 全角分隔符规范化、注释去除和去重处理。 +""" + import re from typing import Any, Iterable diff --git a/module/config/time_source.py b/module/config/time_source.py index b1d487602..d8fd0ed22 100644 --- a/module/config/time_source.py +++ b/module/config/time_source.py @@ -1,3 +1,17 @@ +"""网络时间源模块。 + +通过 NTP 服务器校准本地时间,确保任务调度的时间准确性。 +在长时间运行场景下,系统时钟可能存在漂移, +NTP 校时可以保证委托、科研等定时任务的精确触发。 + +特性: +- 启动时自动校准,后续读取使用缓存的偏移量 +- 支持多个 NTP 服务器,自动故障转移 +- 校时失败时回退到本机时间,不影响运行 +- 可通过环境变量 AZURPILOT_NTP_DISABLE 禁用 +- 可通过环境变量 AZURPILOT_NTP_SERVERS 自定义服务器 +""" + import os import socket import struct @@ -6,11 +20,13 @@ from datetime import datetime, timezone -NTP_EPOCH_DELTA = 2208988800 +# NTP 协议常量 +NTP_EPOCH_DELTA = 2208988800 # NTP 时间纪元与 Unix 时间纪元的差值(秒) NTP_PORT = 123 NTP_PACKET = b'\x1b' + b'\0' * 47 NTP_SERVERS_ENV = 'AZURPILOT_NTP_SERVERS' NTP_DISABLE_ENV = 'AZURPILOT_NTP_DISABLE' +# 默认 NTP 服务器列表(中国优先) DEFAULT_NTP_SERVERS = ( 'ntp.ntsc.ac.cn', 'ntp.aliyun.com', diff --git a/module/config/utils.py b/module/config/utils.py index 44a299526..e8b2e7501 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -1,3 +1,18 @@ +"""配置管理工具函数集。 + +提供配置系统所需的底层工具函数,包括: +- 文件读写:JSON/YAML 文件的安全读写(原子写入) +- 数据解析:配置值的类型转换和解析 +- 服务器时间:各服务器时区计算和重置时间 +- 路径管理:配置文件、资源文件、i18n 文件的路径解析 +- 随机 ID:配置实例的唯一标识生成 + +常量定义: +- LANGUAGES: 支持的语言列表(zh-CN、zh-MIAO、en-US、ja-JP、zh-TW) +- SERVER_TO_LANG: 服务器到语言的映射 +- SERVER_TO_TIMEZONE: 服务器到时区的映射 +""" + # 此文件提供了配置管理相关的通用工具函数。 # 包含 JSON/YAML 读写、数据类型解析转换、服务器特定时间计算以及随机 ID 生成等底层功能。 import json @@ -426,10 +441,10 @@ def get_os_reset_remain(): """ next_reset = get_os_next_reset() now = current_time() - logger.attr('OpsiNextReset', next_reset) + logger.attr('大世界下次重置', next_reset) remain = int((next_reset - now).total_seconds() // 86400) - logger.attr('ResetRemain', remain) + logger.attr('重置剩余天数', remain) return remain diff --git a/module/config/watcher.py b/module/config/watcher.py index 5af4bbce8..3da3e1acb 100644 --- a/module/config/watcher.py +++ b/module/config/watcher.py @@ -1,3 +1,9 @@ +"""配置文件监控模块。 + +定义 ConfigWatcher 类,通过跟踪配置文件的修改时间来检测文件变更, +支持在任务间自动热重载配置,避免重启应用。 +""" + import os from datetime import datetime @@ -26,7 +32,7 @@ def should_reload(self) -> bool: """ mtime = self.get_mtime() if mtime > self.start_mtime: - logger.info(f'Config "{self.config_name}" changed at {mtime}') + logger.info(f'[配置-监视] 配置 "{self.config_name}" 在 {mtime} 发生变更') return True else: return False diff --git a/module/daemon/benchmark.py b/module/daemon/benchmark.py index 59308c48f..a76fd0ac3 100644 --- a/module/daemon/benchmark.py +++ b/module/daemon/benchmark.py @@ -1,3 +1,9 @@ +"""基准测试模块。 + +用于评估设备性能,包括截图速度、图像处理速度和战斗效率。 +使用 Rich 表格输出测试结果,帮助用户选择最佳的截图后端配置。 +""" + import time import typing as t @@ -50,8 +56,8 @@ def benchmark_test(self, func, *args, **kwargs): Raises: RequestHumanTakeover: 不捕获此异常,直接向上抛出。 """ - logger.hr(f'Benchmark test', level=2) - logger.info(f'Testing function: {func.__name__}') + logger.hr(f'基准测试', level=2) + logger.info(f'测试函数: {func.__name__}') record = [] for n in range(1, self.TEST_TOTAL + 1): @@ -75,9 +81,9 @@ def benchmark_test(self, func, *args, **kwargs): ) record.append(cost) - logger.info('Benchmark tests done') + logger.info('基准测试完成') average = float(np.mean(np.sort(record)[:self.TEST_BEST])) - logger.info(f'Time cost {float2str(average)} ({self.TEST_BEST} best results out of {self.TEST_TOTAL} tests)') + logger.info(f'[守护-基准测试] 耗时 {float2str(average)} (最优 {self.TEST_BEST} 次,共 {self.TEST_TOTAL} 次测试)') return average @staticmethod @@ -172,9 +178,9 @@ def benchmark(self, screenshot: t.Tuple[str] = (), click: t.Tuple[str] = ()): Returns: tuple: (最快截图方法, 最快点击方法)。 """ - logger.hr('Benchmark', level=1) - logger.info(f'Testing screenshot methods: {screenshot}') - logger.info(f'Testing click methods: {click}') + logger.hr('基准测试', level=1) + logger.info(f'测试截图方式: {screenshot}') + logger.info(f'测试点击方式: {click}') screenshot_result = [] for method in screenshot: @@ -195,13 +201,13 @@ def compare(res): else: return res - logger.hr('Benchmark Results', level=1) + logger.hr('基准测试结果', level=1) fastest_screenshot = 'ADB_nc' fastest_click = 'minitouch' if screenshot_result: self.show(test='Screenshot', data=screenshot_result, evaluate_func=self.evaluate_screenshot) fastest = sorted(screenshot_result, key=lambda item: compare(item))[0] - logger.info(f'Recommend screenshot method: {fastest[0]} ({float2str(fastest[1])})') + logger.info(f'推荐截图方式: {fastest[0]} ({float2str(fastest[1])})') fastest_screenshot = fastest[0] if click_result: self.show(test='Control', data=click_result, evaluate_func=self.evaluate_click) @@ -209,7 +215,7 @@ def compare(res): # 如果 minitouch 和 MaaTouch 都是最快的,优先选择 MaaTouch if 'MaaTouch' in click and fastest[0] == 'minitouch': fastest[0] = 'MaaTouch' - logger.info(f'Recommend control method: {fastest[0]} ({float2str(fastest[1])})') + logger.info(f'推荐控制方式: {fastest[0]} ({float2str(fastest[1])})') fastest_click = fastest[0] return fastest_screenshot, fastest_click @@ -263,8 +269,8 @@ def run(self): self.device.uninstall_minicap() self.ensure_campaign_ui('7-2', mode='normal') - logger.attr('DeviceType', self.config.Benchmark_DeviceType) - logger.attr('TestScene', self.config.Benchmark_TestScene) + logger.attr('设备类型', self.config.Benchmark_DeviceType) + logger.attr('测试场景', self.config.Benchmark_TestScene) screenshot, click = self.get_test_methods() self.benchmark(screenshot, click) diff --git a/module/daemon/daemon.py b/module/daemon/daemon.py index a14909d42..dd1139556 100644 --- a/module/daemon/daemon.py +++ b/module/daemon/daemon.py @@ -1,3 +1,33 @@ +""" +守护模式(Daemon Mode)模块。 + +后台监控自动化模块,在玩家手动操作时提供辅助支持。 +守护模式持续截图并检测游戏状态,自动处理战斗、地图事件、 +退役、紧急委托等场景,让玩家的战斗体验更加流畅。 + +主要功能: + - 战斗准备和状态处理:自动检测战斗界面并处理战斗结算 + - 地图事件处理:伏击规避、神秘格子处理 + - 地图准备(可选):自动点击地图准备和舰队准备按钮 + - 退役处理:满仓时自动退役多余舰船 + - 紧急委托:检测并处理紧急委托弹窗 + - 弹窗处理:大舰队弹窗、投票弹窗等 + - 剧情跳过:自动跳过剧情对话 + +设计特点: + - 禁用死循环检测(通过 DaemonBase.disable_stuck_detection), + 因为守护模式需要长时间无操作运行 + - 无自动结束条件,需手动停止任务 + - 不主动执行游戏任务,仅辅助处理弹出事件 + +继承关系: + - DaemonBase: 禁用死循环检测的基础类,继承自 ModuleBase + - CampaignBase: 提供战斗、地图、退役等操作能力 + +Pages: + 无固定页面,在当前游戏页面持续运行 +""" + from module.campaign.campaign_base import CampaignBase from module.daemon.daemon_base import DaemonBase from module.exception import CampaignEnd @@ -6,6 +36,32 @@ class AzurLaneDaemon(DaemonBase, CampaignBase): + """ + 守护模式主任务类。 + + 在后台持续监控游戏画面,自动处理各类突发事件。 + 主循环以固定间隔截图,依次检查并处理以下场景: + + 处理优先级: + 1. 跳过正在执行的战斗 + 2. 战斗准备和战斗结算 + 3. 伏击规避和神秘格子 + 4. 地图准备和舰队准备(需开启 Daemon_EnterMap) + 5. 退役处理(船坞满仓时) + 6. 紧急委托 + 7. 大舰队/投票弹窗 + 8. 剧情跳过 + + 属性: + 无额外实例属性 + + 配置项: + Daemon_EnterMap: 是否自动进入地图准备 + + 注意: + 该模式无自动结束条件,运行后需手动停止。 + 由于禁用了死循环检测,长时间无截图变化不会触发异常。 + """ def run(self): while 1: self.device.screenshot() diff --git a/module/daemon/daemon_base.py b/module/daemon/daemon_base.py index 2dca3db64..b511766c5 100644 --- a/module/daemon/daemon_base.py +++ b/module/daemon/daemon_base.py @@ -1,3 +1,9 @@ +"""守护模式基类。 + +继承 ModuleBase 并禁用卡死检测,为所有守护任务提供基础功能。 +守护模式用于后台持续运行,不会因超时自动停止。 +""" + from module.base.base import ModuleBase diff --git a/module/daemon/game_manager.py b/module/daemon/game_manager.py index b10753f96..267bf603b 100644 --- a/module/daemon/game_manager.py +++ b/module/daemon/game_manager.py @@ -1,12 +1,18 @@ +"""游戏管理器。 + +提供强制停止游戏进程的功能,并支持可选的自动重启。 +继承 LoginHandler 以复用登录和重启逻辑。 +""" + from module.handler.login import LoginHandler from module.logger import logger class GameManager(LoginHandler): def run(self): - logger.hr('Force Stop AzurLane', level=1) + logger.hr('强制停止碧蓝航线', level=1) self.device.app_stop() - logger.info('Force Stop finished') + logger.info('[守护-管理] 强制停止完成') if self.config.GameManager_AutoRestart: LoginHandler(config=self.config, device=self.device).app_restart() diff --git a/module/daemon/ocr_benchmark.py b/module/daemon/ocr_benchmark.py index 74f153563..683f3a83d 100644 --- a/module/daemon/ocr_benchmark.py +++ b/module/daemon/ocr_benchmark.py @@ -1,3 +1,7 @@ +"""OCR 基准测试工具,用于评估不同 OCR 模型的识别精度和性能。 +支持 azur_lane、azur_lane_jp、cn 等模型的批量测试, +通过 Rich 表格输出详细的准确率统计结果。""" + import os import platform import shutil @@ -68,7 +72,7 @@ def _rate_speed(avg_ms): return 'Ultra Slow', 'bold red' def _run_single(self, model_name, dataset_prefix, subfolder, use_gpu=None, ocr_device=None): - logger.hr(f'Benchmark: {model_name.upper()} model | dataset: {dataset_prefix}', level=2) + logger.hr(f'基准测试: {model_name.upper()} 模型 | 数据集: {dataset_prefix}', level=2) # --- Dynamic OCR device config --- if ocr_device is None and use_gpu is not None: @@ -88,17 +92,17 @@ def _run_single(self, model_name, dataset_prefix, subfolder, use_gpu=None, ocr_d try: if archive_path: - logger.info(f'Extracting {archive_path} ...') + logger.info(f'[基准测试] 解压 {archive_path} ...') if os.path.exists(extract_dir): shutil.rmtree(extract_dir) shutil.unpack_archive(archive_path, extract_dir) test_cases = self._load_test_cases(extract_dir, subfolder) if not test_cases: - logger.error(f'[{model_name}] UNABLE to load test cases. Skipped.') + logger.error(f'[{model_name}] 无法加载测试用例,已跳过') return None - logger.info(f'[{model_name}] Loaded {len(test_cases)} test cases.') + logger.info(f'[{model_name}] 已加载 {len(test_cases)} 个测试用例') # --- Accuracy --- correct = 0 @@ -112,13 +116,13 @@ def _run_single(self, model_name, dataset_prefix, subfolder, use_gpu=None, ocr_d correct += 1 else: name = os.path.basename(img_input) - logger.warning(f'Fail [{name}]: expected "{expected}", got "{result}"') + logger.warning(f'失败 [{name}]: 预期 "{expected}", 实际 "{result}"') except Exception as e: - logger.error(f'OCR error on {img_input}: {e}') + logger.error(f'[{model_name}] OCR错误 {img_input}: {e}') if idx % log_step == 0 or idx == total: pct = idx / total * 100 - logger.info(f'[{model_name}] Accuracy progress: {idx}/{total} ({pct:.0f}%)') + logger.info(f'[{model_name}] 精度进度: {idx}/{total} ({pct:.0f}%)') accuracy = (correct / total) * 100 if total > 0 else 0 @@ -138,20 +142,20 @@ def _run_single(self, model_name, dataset_prefix, subfolder, use_gpu=None, ocr_d benchmark_img = cv2.imread(test_cases[0][0]) count = 100 - logger.info(f'[{model_name}] Warming up...') + logger.info(f'[{model_name}] 预热中...') for _ in range(3): ocr.ocr(benchmark_img) - logger.info(f'[{model_name}] Running {count} inferences...') + logger.info(f'[{model_name}] 运行 {count} 次推理...') start = time.time() for i in range(1, count + 1): try: ocr.ocr(benchmark_img) except Exception as e: - logger.error(f'[{model_name}] Error on iteration {i}: {e}') + logger.error(f'[{model_name}] 第 {i} 次迭代错误: {e}') break if i % 5 == 0 or i == count: - logger.info(f'[{model_name}] Speed progress: {i}/{count}') + logger.info(f'[{model_name}] 速度进度: {i}/{count}') cost = time.time() - start avg_ms = cost * 1000 / count if cost > 0 else 0 @@ -180,10 +184,10 @@ def _run_single(self, model_name, dataset_prefix, subfolder, use_gpu=None, ocr_d try: shutil.rmtree(extract_dir) except Exception as e: - logger.error(f'Cleanup {extract_dir} failed: {e}') + logger.error(f'[基准测试] 清理 {extract_dir} 失败: {e}') def run(self): - logger.hr('OCR Benchmark', level=1) + logger.hr('OCR基准测试', level=1) results = [] for model_name, dataset_prefix, subfolder in self.BENCHMARKS: @@ -193,8 +197,8 @@ def run(self): # --- Summary --- if not results: - logger.hr('OCR Benchmark Summary', level=1) - logger.error('No benchmark results collected.') + logger.hr('OCR基准测试摘要', level=1) + logger.error('[基准测试] 未收集到基准测试结果') return table = Table(show_lines=True) @@ -223,7 +227,7 @@ def run(self): status ) - logger.hr('OCR Benchmark Summary', level=1) + logger.hr('OCR基准测试摘要', level=1) logger.print(table, justify='center') logger.info('[Daemon] 如果您的 Status 显示 Error 或 Warning,请使用 CPU 运行 OCR') @@ -232,33 +236,33 @@ def run_simple_ocr_benchmark(self): Returns: str: Best OCR device for this machine. """ - logger.hr('Simple OCR Benchmark', level=1) + logger.hr('简单OCR基准测试', level=1) backend = self.config.ocr_backend - logger.info(f'Backend: {backend}') + logger.info(f'[基准测试] 后端: {backend}') if backend == 'ncnn': from module.ocr.ncnn_ocr import has_ncnn_vulkan_gpu if not has_ncnn_vulkan_gpu(): - logger.info('No ncnn Vulkan GPU detected, use CPU.') + logger.info('[基准测试] 未检测到ncnn Vulkan GPU,使用CPU') return 'cpu' - logger.info('Testing OCR with ncnn Vulkan GPU...') + logger.info('[基准测试] 使用ncnn Vulkan GPU测试OCR...') device = 'gpu' else: # ONNX backend if sys.platform == 'darwin' and platform.machine() == 'arm64': - logger.info('Testing OCR with ANE...') + logger.info('[基准测试] 使用ANE测试OCR...') device = 'ane' else: - logger.info('Testing OCR with GPU (DirectML)...') + logger.info('[基准测试] 使用GPU (DirectML) 测试OCR...') device = 'gpu' res = self._run_single('azur_lane', 'sets_num', 'sets_num', ocr_device=device) if res and res['accuracy'] >= 100.0: - logger.info(f'OCR accuracy is 100% with {device.upper()}, use {device.upper()}.') + logger.info(f'[基准测试] 使用 {device.upper()} OCR精度为100%,使用 {device.upper()}') return device else: - logger.info(f'OCR accuracy is not 100% with {device.upper()} or test failed, fallback to CPU.') + logger.info(f'[基准测试] 使用 {device.upper()} OCR精度不为100%或测试失败,回退到CPU') return 'cpu' diff --git a/module/daemon/os_daemon.py b/module/daemon/os_daemon.py index ffb12c480..02dc5a6c8 100644 --- a/module/daemon/os_daemon.py +++ b/module/daemon/os_daemon.py @@ -1,3 +1,10 @@ +"""大世界守护模式。 + +在大世界(Operation Siren)中持续运行,按优先级自动处理: +战斗状态、战斗准备、经验结算、地图事件、港口维修和敌人选择。 +需手动停止,无自动终止条件。 +""" + from module.combat.assets import EXP_INFO_C, EXP_INFO_D from module.daemon.daemon_base import DaemonBase from module.exception import CampaignEnd @@ -60,8 +67,7 @@ def run(self): self.port_dock_repair() self.port_quit() self.interval_reset(PORT_ENTER) - logger.info('Port repair finished, ' - 'please move your fleet out of the port in 30s to avoid repairing again') + logger.info('[守护-大世界] 港口维修完成,请在 30 秒内将舰队移出港口以避免重复维修') # 自动选择最近敌人 if self.config.OpsiDaemon_SelectEnemy: diff --git a/module/daemon/uncensored.py b/module/daemon/uncensored.py index 3d6096588..26689f4b1 100644 --- a/module/daemon/uncensored.py +++ b/module/daemon/uncensored.py @@ -1,3 +1,9 @@ +"""游戏去和谐处理。 + +通过 ADB 推送 localization.txt 文件到模拟器,启用游戏内置的 +本地化皮肤显示。从 Git 仓库拉取最新补丁资源并部署到设备。 +""" + import shutil from deploy.git import GitManager @@ -13,7 +19,7 @@ class AzurLaneUncensored(LoginHandler): def create_level1_uncensored(self): - logger.info('Create level 1 uncensored') + logger.info('创建1级未审查') folder = './files' try: shutil.rmtree(folder) @@ -36,15 +42,15 @@ def run(self): repo = self.config.AzurLaneUncensored_Repository folder = './.venv/AzurLaneUncensored' - logger.hr('Update AzurLaneUncensored', level=1) - logger.info('This will take a while at first use') + logger.hr('更新 AzurLane未审查', level=1) + logger.info('[守护-无删减] 首次使用需要较长时间') manager = GitManager() manager.config['GitExecutable'] = os.path.abspath(manager.config['GitExecutable']) manager.config['AdbExecutable'] = os.path.abspath(manager.config['AdbExecutable']) os.makedirs(folder, exist_ok=True) prev = os.getcwd() - # Running in ./.venv/AzurLaneUncensored + # Running in ./.venv/AzurLane未审查 os.chdir(folder) # Monkey patch `print()` build-in to show logs. self.create_level1_uncensored() @@ -56,22 +62,22 @@ def run(self): # keep_changes=False # ) - logger.hr('Push Uncensored Files', level=1) - logger.info('This will take a few seconds') + logger.hr('推送未审查文件', level=1) + logger.info('[守护-无删减] 推送需要几秒钟') command = ['push', 'files', f'/sdcard/Android/data/{self.device.package}'] - logger.info(f'Command: {command}') + logger.info(f'[守护-无删减] 命令: {command}') self.device.adb_command(command, timeout=30) - logger.info('Push success') + logger.info('[守护-无删减] 推送成功') # Back to root folder os.chdir(prev) - logger.hr('Restart AzurLane', level=1) + logger.hr('重启碧蓝航线', level=1) self.config.override(Error_HandleError=True) self.device.app_stop() self.device.app_start() self.handle_app_login() - logger.info('Uncensored Finished') + logger.info('[守护-无删减] 完成') if __name__ == '__main__': diff --git a/module/daily/assets.py b/module/daily/assets.py index 599443350..12076597c 100644 --- a/module/daily/assets.py +++ b/module/daily/assets.py @@ -1,3 +1,5 @@ +"""每日模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/daily/daily.py b/module/daily/daily.py index 9fd1efbd8..6f965da07 100644 --- a/module/daily/daily.py +++ b/module/daily/daily.py @@ -1,3 +1,16 @@ +"""每日任务处理器。 + +自动完成碧蓝航线的每日任务,包括: +- 每日出击(商船护送、海域突进、斩首行动等) +- 每日任务的完成和奖励领取 +- 剩余次数检测和任务切换 + +每日任务类型通过 DAILY_MISSION_LIST 定义, +每日有固定的出击次数限制,通过 OCR 读取剩余次数。 + +继承自 Combat,可直接调用战斗流程。 +""" + import numpy as np import module.config.server as server @@ -10,6 +23,7 @@ from module.ui.assets import BACK_ARROW, DAILY_CHECK from module.ui.page import page_campaign_menu, page_daily +# 每日任务列表 DAILY_MISSION_LIST = [DAILY_MISSION_1, DAILY_MISSION_2, DAILY_MISSION_3] if server.server != 'jp': OCR_REMAIN = Digit(OCR_REMAIN, threshold=128, alphabet='01234') @@ -19,6 +33,16 @@ class Daily(Combat): + """每日任务执行器。 + + 管理每日任务的选择、执行和完成检测。 + 每个每日任务有独立的活跃状态和剩余次数。 + + Attributes: + daily_current (int): 当前正在处理的每日任务索引。 + daily_checked (list): 已检查过的每日任务列表。 + emergency_module_development (bool): 是否为紧急模块开发任务。 + """ daily_current: int daily_checked: list emergency_module_development = False @@ -29,9 +53,9 @@ def is_active(self): color = (np.max(color) + np.min(color)) / 2 active = color > 30 if active: - logger.attr(f'Daily_{self.daily_current}', 'active') + logger.attr(f'每日任务_{self.daily_current}', '活跃') else: - logger.attr(f'Daily_{self.daily_current}', 'inactive') + logger.attr(f'每日任务_{self.daily_current}', '未活跃') return active def _wait_daily_switch(self): @@ -39,14 +63,14 @@ def _wait_daily_switch(self): def next(self): self.daily_current += 1 - logger.info('Switch to %s' % str(self.daily_current)) + logger.info(f'[每日任务] 切换到 {self.daily_current}') self.device.click(DAILY_NEXT) self._wait_daily_switch() self.device.screenshot() def prev(self): self.daily_current -= 1 - logger.info('Switch to %s' % str(self.daily_current)) + logger.info(f'[每日任务] 切换到 {self.daily_current}') self.device.click(DAILY_PREV) self._wait_daily_switch() self.device.screenshot() @@ -136,7 +160,7 @@ def get_daily_stage_and_fleet(self): stage = stages[self.daily_current] if stage not in dic: - logger.warning(f'Unknown daily stage `{stage}` from daily_current={self.daily_current}') + logger.warning(f'未知的每日关卡 `{stage}` from daily_current={self.daily_current}') stage = dic.get(stage, 0) return int(stage), int(fleet) @@ -170,7 +194,7 @@ def daily_execute(self, remain=3, stage=1, fleet=1): in: page_daily out: page_daily """ - logger.hr(f'Daily {self.daily_current}', level=2) + logger.hr(f'每日任务 {self.daily_current}', level=2) logger.info(f'remain={remain}, stage={stage}, fleet={fleet}') def daily_enter_check(): @@ -184,19 +208,19 @@ def daily_end(): self.ui_click(click_button=DAILY_ENTER, check_button=daily_enter_check, appear_button=DAILY_CHECK, skip_first_screenshot=True) if self.appear(DAILY_LOCKED): - logger.info('Daily locked') + logger.info('每日锁定') self.ui_click(click_button=BACK_ARROW, check_button=DAILY_CHECK) self.device.sleep((1, 1.2)) return False button = DAILY_MISSION_LIST[stage - 1] for n in range(remain): - logger.hr(f'Count {n + 1}') + logger.hr(f'计数 {n + 1}') result = self.daily_enter(button) if not result: break if self.daily_current == self.supply_line_disruption_index: - logger.info('Submarine daily skip not unlocked, skip') + logger.info('潜艇每日跳过未解锁,跳过') self.ui_click(click_button=BACK_ARROW, check_button=daily_enter_check, skip_first_screenshot=True) break # 执行经典每日任务 @@ -266,11 +290,11 @@ def daily_check(self, n=None): if not n: n = self.daily_current self.daily_checked.append(n) - logger.info(f'Checked daily {n}') - logger.info(f'Checked_list: {self.daily_checked}') + logger.info(f'已检查每日 {n}') + logger.info(f'已检查列表: {self.daily_checked}') def daily_run_one(self): - logger.hr('Daily run one', level=1) + logger.hr('每日运行一次', level=1) self.ui_ensure(page_daily) self.device.sleep(0.2) self.device.screenshot() @@ -278,7 +302,7 @@ def daily_run_one(self): self.emergency_module_development = self.appear(ENTRANCE_EMERGENCY_MODULE_DEVELOPMENT, offset=(25, 50)) logger.attr('emergency_module_development', self.emergency_module_development) - logger.info(f'Checked_list: {self.daily_checked}') + logger.info(f'已检查列表: {self.daily_checked}') for _ in range(max(self.daily_checked)): self.next() @@ -286,23 +310,23 @@ def daily_run_one(self): if self.daily_current > 7: break if self.daily_current == self.empty_index: - logger.info('This daily is not open now') + logger.info('此每日当前未开放') self.daily_check() self.next() continue stage, fleet = self.get_daily_stage_and_fleet() if self.daily_current == self.supply_line_disruption_index and not self.config.Daily_UseDailySkip: - logger.info('Skip supply line disruption if UseDailySkip disabled') + logger.info('如UseDailySkip禁用则跳过补给线破坏') self.daily_check() self.next() continue if not stage: - logger.info(f'No stage set on daily_current: {self.daily_current}, skip') + logger.info(f'daily_current未设置关卡,跳过: {self.daily_current}, skip') self.daily_check() self.next() continue if self.daily_current != self.supply_line_disruption_index and not fleet: - logger.info(f'No fleet set on daily_current: {self.daily_current}, skip') + logger.info(f'daily_current未设置舰队,跳过: {self.daily_current}, skip') self.daily_check() self.next() continue @@ -332,7 +356,7 @@ def daily_run(self): self.daily_checked = [0] if max(self.daily_checked) >= 7: - logger.info('Daily clear complete.') + logger.info('每日清除完成') break def run(self): diff --git a/module/device/app_control.py b/module/device/app_control.py index 8c49202c1..b16ee4998 100644 --- a/module/device/app_control.py +++ b/module/device/app_control.py @@ -1,3 +1,9 @@ +"""应用生命周期控制模块。 + +管理 Android 应用(碧蓝航线)的启动、停止、缓存清除等操作, +以及 UI 层级结构(hierarchy)的获取和 XPath 元素查询。 +根据控制方法和模拟器类型自动选择 ADB 或 uiautomator2 后端。 +""" from lxml import etree from module.base.timer import Timer @@ -10,11 +16,30 @@ class AppControl(Adb, WSA, Uiautomator2): + """应用生命周期和 UI 层级管理器。 + + 通过多重继承组合 ADB、WSA 和 uiautomator2 后端,根据控制方法 + 自动分发应用的启动、停止、状态查询操作。提供 UI 层级转储和 + XPath 元素查询功能用于界面状态检测。 + + Attributes: + hierarchy (etree._Element): 最近一次获取的 UI 层级树。 + _app_u2_family (list[str]): 需要使用 uiautomator2 后端的控制方法列表。 + _hierarchy_interval (Timer): 层级获取间隔计时器。 + """ hierarchy: etree._Element _app_u2_family = ['uiautomator2', 'minitouch', 'scrcpy', 'MaaTouch', 'nemu_ipc'] _hierarchy_interval = Timer(0.1) def app_current(self) -> str: + """获取当前前台运行的应用包名。 + + 根据控制方法选择不同的获取方式:WSA 使用 WSA 后端, + uiautomator2 家族方法使用 uiautomator2 后端,其他使用 ADB。 + + Returns: + str: 当前前台应用的包名字符串。 + """ method = self.config.Emulator_ControlMethod if self.is_wsa: package = self.app_current_wsa() @@ -26,13 +51,26 @@ def app_current(self) -> str: return package def app_is_running(self) -> bool: + """检查目标应用(碧蓝航线)是否正在前台运行。 + + 通过比较当前前台应用包名与配置中的包名来判断。 + + Returns: + bool: 应用在前台运行返回 True。 + """ package = self.app_current() - logger.attr('Package_name', package) + logger.attr('应用包名', package) return package == self.package def app_start(self): + """启动目标应用(碧蓝航线)。 + + 根据设备类型和控制方法选择不同的启动方式: + WSA 设备指定 display=0,uiautomator2 家族使用 uiautomator2 启动, + 其他使用 ADB am start。 + """ method = self.config.Emulator_ControlMethod - logger.info(f'App start: {self.package}') + logger.info(f'应用启动: {self.package}') if self.config.Emulator_Serial == 'wsa-0': self.app_start_wsa(display=0) elif method in AppControl._app_u2_family: @@ -41,32 +79,48 @@ def app_start(self): self.app_start_adb() def app_stop(self): + """停止目标应用(碧蓝航线)。 + + 根据控制方法选择 uiautomator2 或 ADB am force-stop 方式。 + """ method = self.config.Emulator_ControlMethod - logger.info(f'App stop: {self.package}') + logger.info(f'应用停止: {self.package}') if method in AppControl._app_u2_family: self.app_stop_uiautomator2() else: self.app_stop_adb() def app_clear(self): + """清除目标应用的缓存目录。 + + 通过 ADB 删除 /sdcard/Android/data/{package}/cache/ 下的文件。 + """ cache_path = f'/sdcard/Android/data/{self.package}/cache/*' - logger.info(f'App clear cache: {cache_path}') + logger.info(f'应用清除缓存: {cache_path}') result = self.adb_shell(['rm', '-rf', cache_path], timeout=30) if result: - logger.info(f'App clear cache result: {result}') + logger.info(f'[设备-应用] 应用清除缓存结果: {result}') def hierarchy_timer_set(self, interval=None): + """设置 UI 层级获取的最小间隔时间。 + + Args: + interval (int, float, optional): 间隔秒数,None 使用默认值 0.1 秒。 + + Raises: + ScriptError: 间隔参数类型不正确时抛出。 + """ if interval is None: interval = 0.1 elif isinstance(interval, (int, float)): # 代码中手动设置时不限制 pass else: - logger.warning(f'Unknown hierarchy interval: {interval}') - raise ScriptError(f'Unknown hierarchy interval: {interval}') + logger.warning(f'[设备-应用] 未知的层级获取间隔: {interval}') + raise ScriptError(f'[设备-应用] 未知的层级获取间隔: {interval}') if interval != self._hierarchy_interval.limit: - logger.info(f'Hierarchy interval set to {interval}s') + logger.info(f'[设备-应用] 层级获取间隔设置为 {interval}s') self._hierarchy_interval.limit = interval def dump_hierarchy(self) -> etree._Element: diff --git a/module/device/connection.py b/module/device/connection.py index 9077c840f..355bd6c2c 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -1,3 +1,6 @@ +"""ADB 连接管理层。封装 adbutils 进行设备连接、端口转发、Shell 命令执行, +处理连接重试、错误恢复和设备序列号管理。""" + import ipaddress import json import logging @@ -135,7 +138,7 @@ def __init__(self, config): # 连接设备 self.adb_connect(wait_device=False) - logger.attr('AdbDevice', self.adb) + logger.attr('ADB设备', self.adb) # 检测包名 self.package = self.config.Emulator_PackageName @@ -143,8 +146,8 @@ def __init__(self, config): self.detect_package() else: set_server(self.package) - logger.attr('PackageName', self.package) - logger.attr('Server', self.config.SERVER) + logger.attr('应用包名', self.package) + logger.attr('服务器', self.config.SERVER) self.check_mumu_app_keep_alive() @@ -173,7 +176,7 @@ def subprocess_run(self, cmd, timeout=10): Returns: str: 命令的标准输出。 """ - logger.info(f'Execute: {cmd}') + logger.info(f'[设备-连接] 执行: {cmd}') # 不再使用 gooey,直接 shell=False process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False) try: @@ -181,13 +184,13 @@ def subprocess_run(self, cmd, timeout=10): except subprocess.TimeoutExpired: process.kill() stdout, stderr = process.communicate() - logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}') + logger.warning(f'[设备-连接] 调用超时 {cmd}, 标准输出={stdout}, 标准错误={stderr}') return stdout @Config.when(DEVICE_OVER_HTTP=True) def adb_command(self, cmd, timeout=10): logger.critical( - f'尝试执行 {cmd}, ' + f'[设备-连接] 尝试执行 {cmd}, ' f'但 adb_command() 在通过 http 连接时不可用: {self.serial}, ' ) raise RequestHumanTakeover @@ -297,7 +300,7 @@ def cpu_abi(self) -> str: """ abi = self.adb_getprop('ro.product.cpu.abi') if not len(abi): - logger.error(f'CPU ABI invalid: "{abi}"') + logger.error(f'[设备-连接] CPU ABI无效: "{abi}"') return abi @cached_property @@ -308,7 +311,7 @@ def sdk_ver(self) -> int: try: return int(sdk) except ValueError: - logger.error(f'SDK version invalid: {sdk}') + logger.error(f'[设备-连接] SDK版本无效: {sdk}') return 0 @@ -327,7 +330,7 @@ def is_avd(self): @retry def is_waydroid(self): res = self.adb_getprop('ro.product.brand') - logger.attr('ro.product.brand', res) + logger.attr('设备品牌', res) return 'waydroid' in res.lower() @cached_property @@ -343,7 +346,7 @@ def is_bluestacks_air(self): # [bst.instance]: [Tiramisu64] # Tiramisu64 是 Android 13,BlueStacks Air 是唯一使用 Android 13 的 BlueStacks 版本 res = self.adb_getprop('bst.installed_images') - logger.attr('bst.installed_images', res) + logger.attr('蓝叠镜像', res) if 'Tiramisu64' in res: return True return False @@ -356,14 +359,14 @@ def is_mumu_pro(self): return False if not self.is_mumu_family: return False - logger.attr('is_mumu_pro', True) + logger.attr('是否MuMu Pro', True) return True @cached_property @retry def nemud_app_keep_alive(self) -> str: res = self.adb_getprop('nemud.app_keep_alive') - logger.attr('nemud.app_keep_alive', res) + logger.attr('MuMu保持活跃', res) return res @cached_property @@ -371,7 +374,7 @@ def nemud_app_keep_alive(self) -> str: def nemud_player_version(self) -> str: # [nemud.player_product_version]: [3.8.27.2950],MuMu 模拟器版本号 res = self.adb_getprop('nemud.player_version') - logger.attr('nemud.player_version', res) + logger.attr('MuMu播放器版本', res) return res @cached_property @@ -379,7 +382,7 @@ def nemud_player_version(self) -> str: def nemud_player_engine(self) -> str: # MuMu 模拟器引擎类型:NEMUX 或 MACPRO res = self.adb_getprop('nemud.player_engine') - logger.attr('nemud.player_engine', res) + logger.attr('MuMu播放器引擎', res) return res def check_mumu_app_keep_alive(self): @@ -398,7 +401,7 @@ def check_mumu_app_keep_alive(self): logger.critical('[Device] 请在MuMu模拟器设置内关闭 "后台挂机时保活运行"') raise RequestHumanTakeover else: - logger.warning(f'Invalid nemud.app_keep_alive value: {res}') + logger.warning(f'[设备-连接] 无效的MuMu保持活跃值: {res}') return False @cached_property @@ -441,14 +444,14 @@ def _nc_server_host_port(self): # BlueStacks Hyper-V 使用 ADB reverse if self.is_bluestacks_hyperv: host = '127.0.0.1' - logger.info(f'Connecting to BlueStacks hyper-v, using host {host}') + logger.info(f'[设备-连接] 连接蓝叠Hyper-V, 使用主机 {host}') port = self.adb_reverse(f'tcp:{self.config.REVERSE_SERVER_PORT}') return host, port, host, self.config.REVERSE_SERVER_PORT # 模拟器监听本机 if self.is_emulator or self.is_over_http: # Mac 模拟器 if self.is_bluestacks_air or self.is_mumu_pro: - logger.info(f'Connecting to local emulator, using host 127.0.0.1') + logger.info(f'[设备-连接] 连接本地模拟器, 使用主机 127.0.0.1') port = random_port(self.config.FORWARD_PORT_RANGE) return '127.0.0.1', port, "10.0.2.2", port # 获取主机 IP @@ -456,12 +459,12 @@ def _nc_server_host_port(self): host = socket.gethostbyname(socket.gethostname()) except socket.gaierror as e: logger.error(e) - logger.error(f'Unknown host name: {socket.gethostname()}') + logger.error(f'[设备-连接] 未知的主机名: {socket.gethostname()}') host = '127.0.0.1' # 修复 Linux AVD 主机地址 if IS_LINUX and host == '127.0.1.1': host = '127.0.0.1' - logger.info(f'Connecting to local emulator, using host {host}') + logger.info(f'[设备-连接] 连接本地模拟器, 使用主机 {host}') port = random_port(self.config.FORWARD_PORT_RANGE) # AVD 实例使用 10.0.2.2 作为客户端地址 if self.is_avd: @@ -470,16 +473,16 @@ def _nc_server_host_port(self): # 局域网设备,监听与目标设备同一网段的主机 if self.is_network_device: hosts = socket.gethostbyname_ex(socket.gethostname())[2] - logger.info(f'Current hosts: {hosts}') + logger.info(f'[设备-连接] 当前主机: {hosts}') ip = ipaddress.ip_address(self.serial.split(':')[0]) for host in hosts: if ip in ipaddress.ip_interface(f'{host}/24').network: - logger.info(f'Connecting to local network device, using host {host}') + logger.info(f'[设备-连接] 连接本地网络设备, 使用主机 {host}') port = random_port(self.config.FORWARD_PORT_RANGE) return host, port, host, port # 其他设备,创建 ADB reverse 并监听 127.0.0.1 host = '127.0.0.1' - logger.info(f'Connecting to unknown device, using host {host}') + logger.info(f'[设备-连接] 连接未知设备, 使用主机 {host}') port = self.adb_reverse(f'tcp:{self.config.REVERSE_SERVER_PORT}') return host, port, host, self.config.REVERSE_SERVER_PORT @@ -491,8 +494,8 @@ def reverse_server(self): """ del_cached_property(self, '_nc_server_host_port') host_port = self._nc_server_host_port - logger.info(f'Reverse server listening on {host_port[0]}:{host_port[1]}, ' - f'client can send data to {host_port[2]}:{host_port[3]}') + logger.info(f'[设备-连接] 反向服务器监听 {host_port[0]}:{host_port[1]}, ' + f'客户端可发送数据到 {host_port[2]}:{host_port[3]}') server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(host_port[:2]) server.settimeout(5) @@ -508,7 +511,7 @@ def nc_command(self): """ if self.is_emulator: sdk = self.sdk_ver - logger.info(f'sdk_ver: {sdk}') + logger.info(f'[设备-连接] SDK版本: {sdk}') if sdk >= 28: # LD Player 9 没有 `nc`,尝试 `busybox nc` # BlueStacks Pie (Android 9) 有 `nc` 但无法发送数据,优先尝试 `busybox nc` @@ -537,7 +540,7 @@ def nc_command(self): # `/system/bin/sh: busybox: inaccessible or not found\n` if 'inaccessible' in result: continue - logger.attr('nc command', command) + logger.attr('nc命令', command) return command logger.error('[Device] 没有可用的 `netcat` 命令,请使用不带 `_nc` 后缀的截图方法') @@ -582,7 +585,7 @@ def _safe_close(s): except socket.timeout: try: output = recv_all(stream, chunk_size=chunk_size) - logger.warning(str(output)) + logger.warning(f'[设备-连接] {output}') finally: _safe_close(stream) raise AdbTimeout('reverse server accept timeout') @@ -626,10 +629,10 @@ def adb_forward(self, remote): for forward in self.adb.forward_list(): if forward.serial == self.serial and forward.remote == remote and forward.local.startswith('tcp:'): if not port: - logger.info(f'Reuse forward: {forward}') + logger.info(f'[设备-连接] 复用端口转发: {forward}') port = int(forward.local[4:]) else: - logger.info(f'Remove redundant forward: {forward}') + logger.info(f'[设备-连接] 移除冗余端口转发: {forward}') self.adb_forward_remove(forward.local) if port: @@ -638,7 +641,7 @@ def adb_forward(self, remote): # 创建新的端口转发 port = random_port(self.config.FORWARD_PORT_RANGE) forward = ForwardItem(self.serial, f'tcp:{port}', remote) - logger.info(f'Create forward: {forward}') + logger.info(f'[设备-连接] 创建端口转发: {forward}') self.adb.forward(forward.local, forward.remote) return port @@ -663,10 +666,10 @@ def adb_reverse(self, remote): for reverse in self.adb.reverse_list(): if reverse.remote == remote and reverse.local.startswith('tcp:'): if not port: - logger.info(f'Reuse reverse: {reverse}') + logger.info(f'[设备-连接] 复用反向代理: {reverse}') port = int(reverse.local[4:]) else: - logger.info(f'Remove redundant reverse: {reverse}') + logger.info(f'[设备-连接] 移除冗余反向代理: {reverse}') self.adb_reverse_remove(reverse.remote) if port: @@ -675,7 +678,7 @@ def adb_reverse(self, remote): # 创建新的 reverse 转发 port = random_port(self.config.FORWARD_PORT_RANGE) reverse = ReverseItem(remote, f'tcp:{port}') - logger.info(f'Create reverse: {reverse}') + logger.info(f'[设备-连接] 创建反向代理: {reverse}') self._adb_reverse_transport(reverse.remote, reverse.local) return port @@ -700,7 +703,7 @@ def adb_forward_remove(self, local): # adbutils.errors.AdbError: listener 'tcp:8888' not found msg = str(e) if re.search(r'listener .*? not found', msg): - logger.warning(f'{type(e).__name__}: {msg}') + logger.warning(f'[设备-连接] {type(e).__name__}: {msg}') else: raise @@ -724,7 +727,7 @@ def adb_reverse_remove(self, local): # adbutils.errors.AdbError: listener 'tcp:8888' not found msg = str(e) if re.search(r'listener .*? not found', msg): - logger.warning(f'{type(e).__name__}: {msg}') + logger.warning(f'[设备-连接] {type(e).__name__}: {msg}') else: raise @@ -768,7 +771,7 @@ def _wait_device_appear(self, serial, first_devices=None): if timeout.reached(): break if first_log: - logger.info(f'Waiting device appear: {serial}') + logger.info(f'[设备-连接] 等待设备出现: {serial}') first_log = False time.sleep(0.05) @@ -791,35 +794,35 @@ def adb_connect(self, wait_device=True): devices = self.list_device() for device in devices: if device.status == 'offline': - logger.warning(f'Device {device.serial} is offline, disconnect it before connecting') + logger.warning(f'[设备-连接] 设备 {device.serial} 离线,连接前先断开') msg = self.adb_client.disconnect(device.serial) if msg: logger.info(msg) elif device.status == 'unauthorized': - logger.error(f'Device {device.serial} is unauthorized, please accept ADB debugging on your device') + logger.error(f'[设备-连接] 设备 {device.serial} 未授权,请在设备上接受ADB调试') elif device.status == 'device': pass else: - logger.warning(f'Device {device.serial} is is having a unknown status: {device.status}') + logger.warning(f'[设备-连接] 设备 {device.serial} 状态未知: {device.status}') # 跳过 emulator-5554 和 Android 手机的连接,因为它们插入后应自动连接 if 'emulator-' in self.serial: if wait_device: if self._wait_device_appear(self.serial, first_devices=devices): - logger.info(f'Serial {self.serial} connected') + logger.info(f'[设备-连接] 序列号 {self.serial} 已连接') return True else: - logger.info(f'Serial {self.serial} is not connected') - logger.info(f'"{self.serial}" is a `emulator-*` serial, skip adb connect') + logger.info(f'[设备-连接] 序列号 {self.serial} 未连接') + logger.info(f'[设备-连接] "{self.serial}" 是 `emulator-*` 序列号,跳过ADB连接') return True if re.match(r'^[a-zA-Z0-9]+$', self.serial): if wait_device: if self._wait_device_appear(self.serial, first_devices=devices): - logger.info(f'Serial {self.serial} connected') + logger.info(f'[设备-连接] 序列号 {self.serial} 已连接') return True else: - logger.info(f'Serial {self.serial} is not connected') - logger.info(f'"{self.serial}" seems to be a Android serial, skip adb connect') + logger.info(f'[设备-连接] 序列号 {self.serial} 未连接') + logger.info(f'[设备-连接] "{self.serial}" 看起来是Android序列号,跳过ADB连接') return True # 尝试连接 @@ -849,13 +852,13 @@ def adb_connect(self, wait_device=True): return True run_once(self.check_mumu_bridge_network)() # 设备不存在 - logger.warning('No such device exists, please restart the emulator or set a correct serial') + logger.warning('[设备-连接] 设备不存在,请重启模拟器或设置正确的序列号') logger.warning('[Device] 该模拟器 Serial 不存在,请重启模拟器或设置正确的 Serial。') logger.warning('[Device] ADB 无法连接至该模拟器,或是模拟器未启动。') raise EmulatorNotRunningError # 连接失败 - logger.warning(f'Failed to connect {self.serial} after 3 trial, assume connected') + logger.warning(f'[设备-连接] 尝试3次后仍无法连接 {self.serial},假设已连接') self.detect_device() return False @@ -892,7 +895,7 @@ def check_mumu_bridge_network(self): serial=self.serial, ) if instance is None: - logger.warning(f'Failed to check check_mumu_bridge_network, emulator instance not found') + logger.warning(f'[设备-连接] 检查MuMu桥接网络失败,模拟器实例未找到') return False file = instance.mumu_vms_config('customer_config.json') try: @@ -900,12 +903,12 @@ def check_mumu_bridge_network(self): s = f.read() data = json.loads(s) except FileNotFoundError: - logger.warning(f'Failed to check check_mumu_bridge_network, file {file} not exists') + logger.warning(f'[设备-连接] 检查MuMu桥接网络失败,文件 {file} 不存在') return False value = deep_get(data, keys='customer.network_bridge_opened', default=None) - logger.attr('customer.network_bridge_opened', value) + logger.attr('网络桥接已开启', value) if str(value).lower() == 'true': - logger.critical('Please turn off "Network Bridging" in the settings of MuMuPlayer') + logger.critical('[设备-连接] 请在MuMuPlayer设置中关闭"网络桥接"') logger.critical('[Device] 请在MuMU模拟器设置中关闭 网络桥接') raise RequestHumanTakeover return True @@ -930,7 +933,7 @@ def adb_disconnect(self): def adb_restart(self): """重启 ADB 客户端。""" - logger.info('Restart adb') + logger.info('[设备-连接] 重启ADB') # 终止当前客户端 self.adb_client.server_kill() # 重新初始化 ADB 客户端 @@ -958,13 +961,13 @@ def adb_reconnect(self): @Config.when(DEVICE_OVER_HTTP=True) def adb_reconnect(self): logger.warning( - f'When connecting a device over http: {self.serial} ' - f'adb_reconnect() is skipped, you may need to restart ATX manually' + f'[设备-连接] 通过HTTP连接设备: {self.serial},' + f'跳过 adb_reconnect(),您可能需要手动重启ATX' ) def install_uiautomator2(self): """初始化 uiautomator2 并移除 minicap。""" - logger.info('Install uiautomator2') + logger.info('[设备-连接] 安装uiautomator2') init = u2.init.Initer(self.adb, loglevel=logging.DEBUG) # MuMu X 没有 ro.product.cpu.abi,从 ro.product.cpu.abilist 中选取 abi if init.abi not in ['x86_64', 'x86', 'arm64-v8a', 'armeabi-v7a', 'armeabi']: @@ -979,7 +982,7 @@ def install_uiautomator2(self): def uninstall_minicap(self): """卸载 minicap。minicap 在部分模拟器上无法工作或会发送压缩图像。""" - logger.info('Removing minicap') + logger.info('[设备-连接] 移除minicap') self.adb_shell(["rm", "/data/local/tmp/minicap"]) self.adb_shell(["rm", "/data/local/tmp/minicap.so"]) @@ -989,7 +992,7 @@ def restart_atx(self): Minitouch 同一时间只支持一个连接,重启 ATX 以踢掉现有连接。 """ - logger.info('Restart ATX') + logger.info('[设备-连接] 重启ATX') atx_agent_path = '/data/local/tmp/atx-agent' self.adb_shell([atx_agent_path, 'server', '--stop']) self.adb_shell([atx_agent_path, 'server', '--nouia', '-d', '--addr', '127.0.0.1:7912']) @@ -997,8 +1000,8 @@ def restart_atx(self): @Config.when(DEVICE_OVER_HTTP=True) def restart_atx(self): logger.warning( - f'When connecting a device over http: {self.serial} ' - f'restart_atx() is skipped, you may need to restart ATX manually' + f'[设备-连接] 通过HTTP连接设备: {self.serial},' + f'跳过 restart_atx(),您可能需要手动重启ATX' ) @staticmethod @@ -1042,13 +1045,13 @@ def get_orientation(self): pass else: o = 0 - logger.warning(f'Invalid device orientation: {o}, assume it is normal') + logger.warning(f'[设备-连接] 无效的设备方向: {o},假设为正常') else: o = 0 - logger.warning('Unable to get device orientation, assume it is normal') + logger.warning('[设备-连接] 无法获取设备方向,假设为正常') self.orientation = o - logger.attr('Device Orientation', f'{o} ({Connection._orientation_description.get(o, "Unknown")})') + logger.attr('设备方向', f'{o} ({Connection._orientation_description.get(o, "Unknown")})') return o @retry @@ -1084,20 +1087,20 @@ def detect_device(self): 如果 serial=='auto' 且只检测到 1 个设备,则使用该设备。 """ - logger.hr('Detect device') + logger.hr('检测设备') available = SelectedGrids([]) devices = SelectedGrids([]) @run_once def brute_force_connect(): - logger.info('Brute force connect') + logger.info('[设备-连接] 强制连接') from deploy.Windows.emulator import EmulatorManager manager = EmulatorManager() manager.brute_force_connect() for _ in range(2): - logger.info('Here are the available devices, ' - 'copy to Alas.Emulator.Serial to use it or set Alas.Emulator.Serial="auto"') + logger.info('[设备-连接] 以下是可用设备,' + '复制到 Alas.Emulator.Serial 中使用,或设置 Alas.Emulator.Serial="auto"') devices = self.list_device() # 显示可用设备 @@ -1105,18 +1108,18 @@ def brute_force_connect(): for device in available: logger.info(device.serial) if not len(available): - logger.info('No available devices') + logger.info('[设备-连接] 无可用设备') # 显示不可用设备 unavailable = devices.delete(available) if len(unavailable): - logger.info('Here are the devices detected but unavailable') + logger.info('[设备-连接] 以下是检测到但不可用的设备') for device in unavailable: logger.info(f'{device.serial} ({device.status})') # 暴力连接 if self.config.Emulator_Serial == 'auto' and available.count == 0: - logger.warning(f'No available device found') + logger.warning(f'[设备-连接] 未找到可用设备') if IS_WINDOWS: brute_force_connect() continue @@ -1128,24 +1131,24 @@ def brute_force_connect(): # 自动设备检测 if self.config.Emulator_Serial == 'auto': if available.count == 0: - logger.critical('[Device] 没有找到可用设备,自动设备检测无法工作,' + logger.critical('[设备-连接] 没有找到可用设备,自动设备检测无法工作,' '请在 Alas.Emulator.Serial 中设置一个确切的序列号,而不是使用 "auto"') raise RequestHumanTakeover elif available.count == 1: - logger.info(f'[Device] 自动设备检测只找到一个设备,正在使用它') + logger.info(f'[设备-连接] 自动设备检测只找到一个设备,正在使用它') self.config.Emulator_Serial = self.serial = available[0].serial del_cached_property(self, 'adb') elif available.count == 2 \ and available.select(serial='127.0.0.1:7555') \ and available.select(may_mumu12_family=True): - logger.info(f'[Device] 自动设备检测到 MuMu12 设备,正在使用它') + logger.info(f'[设备-连接] 自动设备检测到 MuMu12 设备,正在使用它') # 对于 MuMu12 序列号如 127.0.0.1:7555 和 127.0.0.1:16384 # 忽略 7555,使用 16384 remain = available.select(may_mumu12_family=True).first_or_none() self.config.Emulator_Serial = self.serial = remain.serial del_cached_property(self, 'adb') else: - logger.critical('[Device] 找到多个设备,自动设备检测无法决定选择哪个,' + logger.critical('[设备-连接] 找到多个设备,自动设备检测无法决定选择哪个,' '请将下面列出的可用设备之一复制到 Alas.Emulator.Serial 中') raise RequestHumanTakeover @@ -1161,21 +1164,21 @@ def brute_force_connect(): # 找到配对设备,检查状态以获取正确的序列号 if port_device.status == 'device' and emu_device.status == 'offline': self.serial = port_serial - logger.info(f'LDPlayer device pair found: {port_device}, {emu_device}. ' - f'Using serial: {self.serial}') + logger.info(f'[设备-连接] 找到雷电模拟器设备对: {port_device}, {emu_device}。' + f'使用序列号: {self.serial}') elif port_device.status == 'offline' and emu_device.status == 'device': self.serial = emu_serial - logger.info(f'LDPlayer device pair found: {port_device}, {emu_device}. ' - f'Using serial: {self.serial}') + logger.info(f'[设备-连接] 找到雷电模拟器设备对: {port_device}, {emu_device}。' + f'使用序列号: {self.serial}') elif not devices.select(serial=self.serial): # 当前序列号未找到 if port_device and not emu_device: - logger.info(f'Current serial {self.serial} not found but paired device {port_serial} found. ' - f'Using serial: {port_serial}') + logger.info(f'[设备-连接] 当前序列号 {self.serial} 未找到,但找到配对设备 {port_serial}。' + f'使用序列号: {port_serial}') self.serial = port_serial if not port_device and emu_device: - logger.info(f'Current serial {self.serial} not found but paired device {emu_serial} found. ' - f'Using serial: {emu_serial}') + logger.info(f'[设备-连接] 当前序列号 {self.serial} 未找到,但找到配对设备 {emu_serial}。' + f'使用序列号: {emu_serial}') self.serial = emu_serial # 将 MuMu12 从 127.0.0.1:7555 重定向到 127.0.0.1:16xxx @@ -1184,7 +1187,7 @@ def brute_force_connect(): mumu12 = available.select(may_mumu12_family=True) if mumu12.count == 1: emu_serial = mumu12.first_or_none().serial - logger.warning(f'Redirect MuMu12 {self.serial} to {emu_serial}') + logger.warning(f'[设备-连接] 重定向MuMu12 {self.serial} 到 {emu_serial}') self.config.Emulator_Serial = self.serial = emu_serial break elif mumu12.count >= 2: @@ -1195,7 +1198,7 @@ def brute_force_connect(): if self.is_mumu_over_version_356: # is_mumu_over_version_356 和 nemud_app_keep_alive 已缓存 # 因为是同一设备,可以接受 - logger.warning(f'Device {self.serial} is MuMu12 but corresponding port not found') + logger.warning(f'[设备-连接] 设备 {self.serial} 是MuMu12但未找到对应端口') if IS_WINDOWS: brute_force_connect() devices = self.list_device() @@ -1204,7 +1207,7 @@ def brute_force_connect(): for device in available: logger.info(device.serial) if not len(available): - logger.info('No available devices') + logger.info('[设备-连接] 无可用设备') continue else: # MuMu6 @@ -1223,7 +1226,7 @@ def brute_force_connect(): for device in available.select(may_mumu12_family=True): if -2 <= device.port - self.port <= 2: # 端口已切换 - logger.info(f'MuMu12 serial switched {self.serial} -> {device.serial}') + logger.info(f'[设备-连接] MuMu12序列号切换 {self.serial} -> {device.serial}') del_cached_property(self, 'port') del_cached_property(self, 'is_mumu12_family') del_cached_property(self, 'is_mumu_family') @@ -1238,7 +1241,7 @@ def list_package(self, show_log=True): """ # 约 80ms if show_log: - logger.info('Get package list') + logger.info('[设备-连接] 获取包列表') output = self.adb_shell(r'dumpsys package | grep "Package \["') packages = re.findall(r'Package \[([^\s]+)\]', output) if len(packages): @@ -1246,7 +1249,7 @@ def list_package(self, show_log=True): # 约 200ms if show_log: - logger.info('Get package list') + logger.info('[设备-连接] 获取包列表') output = self.adb_shell(['pm', 'list', 'packages']) packages = re.findall(r'package:([^\s]+)', output) return packages @@ -1266,34 +1269,34 @@ def list_known_packages(self, show_log=True): def detect_package(self, set_config=True): """检测设备上的碧蓝航线客户端包。""" - logger.hr('Detect package') + logger.hr('检测应用包') packages = self.list_known_packages() # 显示可用包 - logger.info(f'Here are the available packages in device "{self.serial}", ' - f'copy to Alas.Emulator.PackageName to use it') + logger.info(f'[设备-连接] 以下是设备 "{self.serial}" 上的可用包,' + f'复制到 Alas.Emulator.PackageName 中使用') if len(packages): for package in packages: logger.info(package) else: - logger.info(f'[Device] 在设备 "{self.serial}" 上没有找到可用包') + logger.info(f'[设备-连接] 在设备 "{self.serial}" 上没有找到可用包') # 自动包检测 if len(packages) == 0: - logger.critical(f'[Device] 没有找到碧蓝航线包,' + logger.critical(f'[设备-连接] 没有找到碧蓝航线包,' f'请确认碧蓝航线已安装在设备 "{self.serial}" 上') raise RequestHumanTakeover if len(packages) == 1: - logger.info('[Device] 自动包检测只找到一个包,正在使用它') + logger.info('[设备-连接] 自动包检测只找到一个包,正在使用它') self.package = packages[0] # 写入配置 if set_config: self.config.Emulator_PackageName = self.package # 设置服务器 - logger.info('Server changed, release resources') + logger.info('[设备-连接] 服务器已变更,释放资源') set_server(self.package) else: logger.critical( - f'找到多个碧蓝航线包,自动包检测无法决定选择哪个,' + '[设备-连接] 找到多个碧蓝航线包,自动包检测无法决定选择哪个,' '请将上面列出的可用包之一复制到 Alas.Emulator.PackageName 中') raise RequestHumanTakeover diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index 548410230..0c00119b6 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -56,11 +56,11 @@ def download_adb_binary(self, target): """ url = platform_tools_url() if url is None: - logger.warning(f'[Device] 当前平台不支持自动下载 ADB: {sys.platform}') + logger.warning(f'[设备] 当前平台不支持自动下载 ADB: {sys.platform}') return None if not target: - logger.warning('[Device] ADB 下载失败,目标路径为空') + logger.warning('[设备] ADB 下载失败,目标路径为空') return None target = Path(target).resolve() @@ -72,14 +72,14 @@ def download_adb_binary(self, target): executable = 'adb.exe' if os.name == 'nt' else 'adb' source = tools_dir / executable - logger.hr('Download ADB', level=2) - logger.warning(f'[Device] 未找到 ADB,正在下载 Android platform-tools: {url}') + logger.hr('下载ADB', level=2) + logger.warning(f'[设备] 未找到 ADB,正在下载 Android platform-tools: {url}') tools_dir.parent.mkdir(parents=True, exist_ok=True) try: urllib.request.urlretrieve(url, archive) except Exception as e: archive.unlink(missing_ok=True) - logger.warning(f'[Device] ADB 下载失败: {e}') + logger.warning(f'[设备] ADB 下载失败: {e}') return None if tools_dir.exists(): @@ -91,7 +91,7 @@ def download_adb_binary(self, target): archive.unlink(missing_ok=True) if not source.exists(): - logger.warning(f'[Device] ADB 下载失败,未找到 {source}') + logger.warning(f'[设备] ADB 下载失败,未找到 {source}') return None if os.name != 'nt': @@ -107,7 +107,7 @@ def download_adb_binary(self, target): else: target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - logger.info(f'[Device] ADB 已安装: {target}') + logger.info(f'[设备] ADB 已安装: {target}') return str(target).replace('\\\\', '/').replace('\\', '/') def __init__(self, config): @@ -115,16 +115,16 @@ def __init__(self, config): Args: config (AzurLaneConfig, str): Name of the user config under ./config """ - logger.hr('Device', level=1) + logger.hr('设备', level=1) if isinstance(config, str): self.config = AzurLaneConfig(config, task=None) else: self.config = config - logger.attr('IS_ON_PHONE_CLOUD', IS_ON_PHONE_CLOUD) + logger.attr('是否云手机', IS_ON_PHONE_CLOUD) # Init adb client - logger.attr('AdbBinary', self.adb_binary) + logger.attr('ADB路径', self.adb_binary) # Monkey patch to custom adb adbutils.adb_path = lambda: self.adb_binary # Remove global proxies, or uiautomator2 will go through it @@ -191,7 +191,7 @@ def serial_check(self): # fool-proof new = self.revise_serial(self.serial) if new != self.serial: - logger.warning(f'Serial "{self.config.Emulator_Serial}" is revised to "{new}"') + logger.warning(f'[设备-属性] 序列号 "{self.config.Emulator_Serial}" 已修正为 "{new}"') self.config.Emulator_Serial = new self.serial = new if self.is_bluestacks4_hyperv: @@ -199,8 +199,8 @@ def serial_check(self): if self.is_bluestacks5_hyperv: self.serial = self.find_bluestacks5_hyperv(self.serial) if "127.0.0.1:58526" in self.serial: - logger.warning('Serial 127.0.0.1:58526 seems to be WSA, ' - 'please use "wsa-0" or others instead') + logger.warning('[设备-属性] 序列号 127.0.0.1:58526 疑似 WSA 设备,' + '请改用 "wsa-0" 或其他格式') raise RequestHumanTakeover if self.is_wsa: self.serial = '127.0.0.1:58526' @@ -305,8 +305,8 @@ def find_bluestacks4_hyperv(serial): """ from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx - logger.info("Use BlueStacks4 Hyper-V Beta") - logger.info("Reading Realtime adb port") + logger.info("使用蓝叠4 Hyper-V测试版") + logger.info("读取实时ADB端口") if serial == "bluestacks4-hyperv": folder_name = "Android" @@ -319,12 +319,11 @@ def find_bluestacks4_hyperv(serial): port = QueryValueEx(key, "BstAdbPort")[0] except FileNotFoundError: logger.error( - rf'Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config') - logger.error('Please confirm that your are using BlueStack 4 hyper-v and not regular BlueStacks 4') - logger.error(r'Please check if there is any other emulator instances under ' - r'registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests') + rf'[设备-蓝叠] 无法找到注册表 HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests\{folder_name}\Config') + logger.error('[设备-蓝叠] 请确认您使用的是BlueStack 4 hyper-v而不是普通BlueStacks 4') + logger.error(r'[设备-蓝叠] 请检查注册表 HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_bgp64_hyperv\Guests 下是否有其他模拟器实例') raise RequestHumanTakeover - logger.info(f"New adb port: {port}") + logger.info(f"新ADB端口: {port}") return f"127.0.0.1:{port}" @staticmethod @@ -340,8 +339,8 @@ def find_bluestacks5_hyperv(serial): """ from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx - logger.info("Use BlueStacks5 Hyper-V") - logger.info("Reading Realtime adb port") + logger.info("使用蓝叠5 Hyper-V") + logger.info("读取实时ADB端口") if serial == "bluestacks5-hyperv": parameter_name = r"bst\.instance\.(Nougat64|Pie64|Rvc64)\.status\.adb_port" @@ -356,20 +355,20 @@ def find_bluestacks5_hyperv(serial): with OpenKey(HKEY_LOCAL_MACHINE, r"SOFTWARE\BlueStacks_nxt_cn") as key: directory = QueryValueEx(key, 'UserDefinedDir')[0] except FileNotFoundError: - logger.error('Unable to find registry HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt ' - 'or HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt_cn') - logger.error('Please confirm that you are using BlueStacks 5 hyper-v and not regular BlueStacks 5') + logger.error('[设备-属性] 未找到注册表 HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt ' + '或 HKEY_LOCAL_MACHINE\SOFTWARE\BlueStacks_nxt_cn') + logger.error('[设备-属性] 请确认使用的是蓝叠 5 Hyper-V 版本,而非普通蓝叠 5') raise RequestHumanTakeover - logger.info(f"Configuration file directory: {directory}") + logger.info(f"配置文件目录: {directory}") with open(os.path.join(directory, 'bluestacks.conf'), encoding='utf-8') as f: content = f.read() port = re.search(rf'{parameter_name}="(\d+)"', content) if port is None: - logger.warning(f"Did not match the result: {serial}.") + logger.warning(f"未匹配结果: {serial}.") raise RequestHumanTakeover port = port.group(2) - logger.info(f"Match to dynamic port: {port}") + logger.info(f"匹配到动态端口: {port}") return f"127.0.0.1:{port}" @cached_property @@ -436,17 +435,34 @@ def adb_client(self) -> AdbClient: try: port = int(env) except ValueError: - logger.warning(f'Invalid environ variable ANDROID_ADB_SERVER_PORT={port}, using default port') + logger.warning(f'无效的环境变量 ANDROID_ADB_SERVER_PORT={port}, 使用默认端口') - logger.attr('AdbClient', f'AdbClient({host}, {port})') + logger.attr('ADB客户端', f'AdbClient({host}, {port})') return AdbClient(host, port) @cached_property def adb(self) -> AdbDevice: + """获取 ADB 设备实例。 + + Returns: + AdbDevice: 通过 ADB 客户端和序列号绑定的设备对象。 + """ return AdbDevice(self.adb_client, self.serial) @cached_property def u2(self) -> u2.Device: + """获取 uiautomator2 设备实例。 + + 根据连接类型选择不同的连接方式: + - HTTP 设备使用 u2.connect() + - 本地模拟器(emulator- 或 127.0.0.1:)使用 u2.connect_usb() + - 其他设备使用 u2.connect() + + 设置命令超时为 7 天(604800 秒)以保持长连接。 + + Returns: + u2.Device: uiautomator2 设备对象。 + """ if self.is_over_http: # Using uiautomator2_http device = u2.connect(self.serial) diff --git a/module/device/control.py b/module/device/control.py index 1e29eb7c6..815d26344 100644 --- a/module/device/control.py +++ b/module/device/control.py @@ -1,3 +1,9 @@ +"""设备输入控制模块。 + +统一管理所有触控操作(点击、长按、滑动、拖拽),根据配置的控制方法 +(ADB、uiautomator2、minitouch、Hermit、MaaTouch、scrcpy、nemu_ipc) +自动分发到对应的底层实现。 +""" from module.base.button import Button from module.base.decorator import cached_property from module.base.timer import Timer @@ -11,12 +17,24 @@ class Control(Hermit, Minitouch, Scrcpy, MaaTouch, NemuIpc): + """设备触控控制调度器。 + + 通过多重继承组合所有控制后端(Hermit、Minitouch、Scrcpy、MaaTouch、NemuIpc), + 根据用户配置的 Emulator_ControlMethod 自动分发到对应后端实现。 + 提供统一的点击、长按、滑动、拖拽接口。 + """ def handle_control_check(self, button): # 将在 Device 中被重写 pass @cached_property def click_methods(self): + """返回控制方法名到点击实现的映射字典。 + + Returns: + dict[str, Callable]: 键为控制方法名(如 'ADB'、'minitouch'), + 值为对应的点击方法。 + """ return { 'ADB': self.click_adb, 'uiautomator2': self.click_uiautomator2, @@ -38,7 +56,7 @@ def click(self, button, control_check=True): x, y = random_rectangle_point(button.button) x, y = ensure_int(x, y) logger.info( - 'Click %s @ %s' % (point2str(x, y), button) + '[设备-控制] 点击 %s @ %s' % (point2str(x, y), button) ) method = self.click_methods.get( self.config.Emulator_ControlMethod, @@ -47,6 +65,13 @@ def click(self, button, control_check=True): method(x, y) def multi_click(self, button, n, interval=(0.1, 0.2)): + """对按钮执行多次连续点击。 + + Args: + button (button.Button): 碧蓝航线按钮实例。 + n (int): 点击次数。 + interval (tuple): 两次点击之间的间隔范围(秒),格式为 (最小值, 最大值)。 + """ self.handle_control_check(button) click_timer = Timer(0.1) for _ in range(n): @@ -69,7 +94,7 @@ def long_click(self, button, duration=(1, 1.2)): x, y = ensure_int(x, y) duration = ensure_time(duration) logger.info( - 'Click %s @ %s, %s' % (point2str(x, y), button, duration) + '[设备-控制] 长按 %s @ %s, %s' % (point2str(x, y), button, duration) ) method = self.config.Emulator_ControlMethod if method == 'minitouch': @@ -86,24 +111,36 @@ def long_click(self, button, duration=(1, 1.2)): self.swipe_adb((x, y), (x, y), duration) def swipe(self, p1, p2, duration=(0.1, 0.2), name='SWIPE', distance_check=True): + """在两点之间执行滑动操作。 + + ADB 方式的滑动持续时间会自动乘以 2.5 以保证有效性。 + 距离检查会丢弃小于 10 像素的滑动(碧蓝航线会将其视为点击)。 + + Args: + p1 (tuple): 起始坐标 (x, y)。 + p2 (tuple): 终点坐标 (x, y)。 + duration (int, float, tuple): 滑动持续时间(秒)。 + name (str): 滑动操作名称,用于日志输出。 + distance_check (bool): 是否检查滑动距离,距离过小时跳过操作。 + """ self.handle_control_check(name) p1, p2 = ensure_int(p1, p2) duration = ensure_time(duration) method = self.config.Emulator_ControlMethod if method == 'uiautomator2': - logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) + logger.info('[设备-控制] 滑动 %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) elif method in ['minitouch', 'MaaTouch', 'scrcpy', 'nemu_ipc']: - logger.info('Swipe %s -> %s' % (point2str(*p1), point2str(*p2))) + logger.info('[设备-控制] 滑动 %s -> %s' % (point2str(*p1), point2str(*p2))) else: # ADB 需要更慢的速度,否则滑动可能无效 duration *= 2.5 - logger.info('Swipe %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) + logger.info('[设备-控制] 滑动 %s -> %s, %s' % (point2str(*p1), point2str(*p2), duration)) if distance_check: if np.linalg.norm(np.subtract(p1, p2)) < 10: # 需要滑动一定距离,否则碧蓝航线会将其视为点击 # uiautomator2 需要 >= 6px,minitouch 需要 >= 5px - logger.info('Swipe distance < 10px, dropped') + logger.info('[设备-控制] 滑动 距离 < 10px,丢弃') return if method == 'minitouch': @@ -147,10 +184,26 @@ def swipe_vector(self, vector, box=(123, 159, 1175, 628), random_range=(0, 0, 0, def drag(self, p1, p2, segments=1, shake=(0, 15), point_random=(-10, -10, 10, 10), shake_random=(-5, -5, 5, 5), swipe_duration=0.25, shake_duration=0.1, name='DRAG'): + """执行拖拽操作,支持分段滑动和松手后的抖动模拟。 + + 用于碧蓝航线中需要精确拖拽的场景(如装备拖放、编队调整)。 + 不支持拖拽的后端会回退到 ADB 滑动 + 点击。 + + Args: + p1 (tuple): 起始坐标 (x, y)。 + p2 (tuple): 终点坐标 (x, y)。 + segments (int): 滑动分段数。 + shake (tuple): 松手后的抖动偏移量 (x, y)。 + point_random (tuple): 起点随机偏移范围 (x_min, y_min, x_max, y_max)。 + shake_random (tuple): 抖动的随机偏移范围 (x_min, y_min, x_max, y_max)。 + swipe_duration (float): 滑动持续时间(秒)。 + shake_duration (float): 抖动持续时间(秒)。 + name (str): 拖拽操作名称,用于日志输出。 + """ self.handle_control_check(name) p1, p2 = ensure_int(p1, p2) logger.info( - 'Drag %s -> %s' % (point2str(*p1), point2str(*p2)) + '[设备-控制] 拖拽 %s -> %s' % (point2str(*p1), point2str(*p2)) ) method = self.config.Emulator_ControlMethod if method == 'minitouch': @@ -166,12 +219,21 @@ def drag(self, p1, p2, segments=1, shake=(0, 15), point_random=(-10, -10, 10, 10 elif method == 'nemu_ipc': self.drag_nemu_ipc(p1, p2, point_random=point_random) else: - logger.warning(f'Control method {method} does not support drag well, ' - f'falling back to ADB swipe may cause unexpected behaviour') + logger.warning(f'[设备-控制] 控制方式 {method} 不支持拖拽,' + f'回退到 ADB 滑动可能导致意外行为') self.swipe_adb(p1, p2, duration=ensure_time(swipe_duration * 2)) self.click(Button(area=(), color=(), button=area_offset(point_random, p2), name=name), False) def island_swipe_hold(self, p1, p2, hold_time): + """岛屿系统专用的滑动并保持操作。 + + 在两点之间滑动并在终点保持一段时间,用于岛屿内的交互操作。 + + Args: + p1 (tuple): 起始坐标 (x, y)。 + p2 (tuple): 终点坐标 (x, y)。 + hold_time (int, float, tuple): 在终点保持的时间(秒)。 + """ p1, p2 = ensure_int(p1, p2) hold_time = ensure_time(hold_time) method = self.config.Emulator_ControlMethod diff --git a/module/device/device.py b/module/device/device.py index cdd6e87c9..4d31f50fb 100644 --- a/module/device/device.py +++ b/module/device/device.py @@ -1,3 +1,6 @@ +"""设备交互的综合管理入口。整合截图、控制、输入和应用管理功能, +内置防卡死检测(GameStuckError)和点击频率控制(GameTooManyClickError)。""" + # 此文件定义了 Device 类,是脚本与设备交互的综合管理入口。 # 负责整合截图、点击、输入功能,并由于内置了防卡死检测和点击频率控制,能有效提高脚本自动化运行的稳定性。 import collections @@ -64,7 +67,7 @@ def format_(file, line, func): return f'{file} {line} {func}' func_list = [f'\n{format_(*row)}' for row in func_list] - logger.info('Function calls:' + ''.join(func_list)) + logger.info('函数调用:' + ''.join(func_list)) class Device(Screenshot, Control, AppControl, Input): @@ -120,7 +123,7 @@ def __init__(self, *args, **kwargs): try: self.platform.boost_running_emulator_priority() except Exception as e: - logger.warning(f'Failed to boost emulator priority: {e}') + logger.warning(f'[设备-模拟器] 提升模拟器优先级失败: {e}') self.screenshot_interval_set() self.method_check() @@ -184,7 +187,7 @@ def run_simple_screenshot_benchmark(self): """ 运行截图方式基准测试,每种方式测试 3 次,选择最快的写入配置。 """ - logger.info('run_simple_screenshot_benchmark') + logger.info('[设备-基准测试] 运行截图方式基准测试') # 先检查分辨率 self.resolution_check_uiautomator2() # 执行基准测试 @@ -203,7 +206,7 @@ def run_simple_ocr_benchmark(self): 准确率 100% 则选择 'gpu',否则回退到 'cpu'。 """ - logger.info('run_simple_ocr_benchmark') + logger.info('[设备-基准测试] 运行OCR设备基准测试') from module.daemon.ocr_benchmark import OcrBenchmark bench = OcrBenchmark(config=self.config, device=self) device = bench.run_simple_ocr_benchmark() @@ -229,25 +232,25 @@ def method_check(self): # self.config.Emulator_ControlMethod = 'minitouch' # Hermit 仅允许在 VMOS 上使用 if self.config.Emulator_ControlMethod == 'Hermit' and not self.is_vmos: - logger.warning('ControlMethod Hermit is allowed on VMOS only') + logger.warning('[设备-方法] 控制方式Hermit仅允许在VMOS上使用') self.config.Emulator_ControlMethod = 'MaaTouch' if self.config.Emulator_ScreenshotMethod == 'ldopengl' \ and self.config.Emulator_ControlMethod == 'minitouch': - logger.warning('Use MaaTouch on ldplayer') + logger.warning('[设备-方法] 雷电模拟器请使用MaaTouch') self.config.Emulator_ControlMethod = 'MaaTouch' # nemu_ipc 和 ldopengl 在非对应模拟器上回退到 auto if self.config.Emulator_ScreenshotMethod == 'nemu_ipc': if not (self.is_emulator and self.is_mumu_family): - logger.warning('ScreenshotMethod nemu_ipc is available on MuMu Player 12 only, fallback to auto') + logger.warning('[设备-方法] 截图方式nemu_ipc仅支持MuMu模拟器12,回退到auto') self.config.Emulator_ScreenshotMethod = 'auto' if self.config.Emulator_ScreenshotMethod == 'ldopengl': if not (self.is_emulator and self.is_ldplayer_bluestacks_family): - logger.warning('ScreenshotMethod ldopengl is available on LD Player only, fallback to auto') + logger.warning('[设备-方法] 截图方式ldopengl仅支持雷电模拟器,回退到auto') self.config.Emulator_ScreenshotMethod = 'auto' if not IS_WINDOWS and self.config.Emulator_ScreenshotMethod in ['nemu_ipc', 'ldopengl']: - logger.warning(f'ScreenshotMethod {self.config.Emulator_ScreenshotMethod} is available on Windows only, ' - f'fallback to auto') + logger.warning(f'[设备-方法] 截图方式{self.config.Emulator_ScreenshotMethod}仅支持Windows,' + f'回退到auto') self.config.Emulator_ScreenshotMethod = 'auto' def handle_night_commission(self, daily_trigger='21:00', threshold=30): @@ -268,7 +271,7 @@ def handle_night_commission(self, daily_trigger='21:00', threshold=30): return False if GET_MISSION.match(self.image, offset=True): - logger.info('Night commission appear.') + logger.info('[设备-委托] 夜间委托出现') self.click(GET_MISSION) return True @@ -287,7 +290,7 @@ def screenshot(self): super().screenshot() except RequestHumanTakeover: if not self.ascreencap_available: - logger.error('aScreenCap unavailable on current device, fallback to auto') + logger.error('[设备-截图] 当前设备aScreenCap不可用,回退到auto') self.run_simple_screenshot_benchmark() super().screenshot() else: @@ -343,12 +346,12 @@ def _check_image_stuck(self): self._stuck_image_timer.start() if self._stuck_image_timer.reached(): show_function_call() - logger.warning(f'Screenshot unchanged for over {self._stuck_image_timer.limit}s') + logger.warning(f'[设备-卡死] 截图超过 {self._stuck_image_timer.limit}s 未变化') self.stuck_record_clear() if self.app_is_running(): - raise GameStuckError('Screenshot not changing') + raise GameStuckError('[设备-卡死] 截图未变化') else: - raise GameNotRunningError('Game died') + raise GameNotRunningError('[设备-卡死] 游戏已退出') else: self._prev_fingerprint = fp self._stuck_image_timer.clear() @@ -372,14 +375,14 @@ def stuck_record_check(self): return False show_function_call() - logger.warning('Wait too long') - logger.warning(f'Waiting for {self.detect_record}') + logger.warning('[设备-卡死] 等待时间过长') + logger.warning(f'[设备-卡死] 等待中: {self.detect_record}') self.stuck_record_clear() if self.app_is_running(): - raise GameStuckError(f'Wait too long') + raise GameStuckError('[设备-卡死] 等待时间过长') else: - raise GameNotRunningError('Game died') + raise GameNotRunningError('[设备-卡死] 游戏已退出') def handle_control_check(self, button): self.stuck_record_clear() @@ -423,22 +426,22 @@ def click_record_check(self): count = collections.Counter(self.click_record).most_common(2) if count[0][1] >= 12: show_function_call() - logger.warning(f'Too many click for a button: {count[0][0]}') - logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') + logger.warning(f'[设备-点击] 按钮点击次数过多: {count[0][0]}') + logger.warning(f'[设备-点击] 点击历史: {[str(prev) for prev in self.click_record]}') self.click_record_clear() - raise GameTooManyClickError(f'Too many click for a button: {count[0][0]}') + raise GameTooManyClickError(f'[设备-点击] 按钮点击次数过多: {count[0][0]}') if len(count) >= 2 and count[0][1] >= 6 and count[1][1] >= 6: show_function_call() - logger.warning(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}') - logger.warning(f'History click: {[str(prev) for prev in self.click_record]}') + logger.warning(f'[设备-点击] 两个按钮交替点击次数过多: {count[0][0]}, {count[1][0]}') + logger.warning(f'[设备-点击] 点击历史: {[str(prev) for prev in self.click_record]}') self.click_record_clear() - raise GameTooManyClickError(f'Too many click between 2 buttons: {count[0][0]}, {count[1][0]}') + raise GameTooManyClickError(f'[设备-点击] 两个按钮交替点击次数过多: {count[0][0]}, {count[1][0]}') def disable_stuck_detection(self): """ 禁用卡死检测,用于半自动模式和调试场景。 """ - logger.info('Disable stuck detection') + logger.info('[设备-检测] 禁用卡死检测') def empty_function(*arg, **kwargs): return False diff --git a/module/device/env.py b/module/device/env.py index ed16c5d43..5efe70626 100644 --- a/module/device/env.py +++ b/module/device/env.py @@ -1,3 +1,6 @@ +"""平台环境检测常量。定义 IS_WINDOWS、IS_MACINTOSH、IS_LINUX 等 +操作系统标识变量,供设备层各模块使用。""" + import sys IS_WINDOWS = sys.platform == 'win32' diff --git a/module/device/input.py b/module/device/input.py index f83a318f1..ecd98cbad 100644 --- a/module/device/input.py +++ b/module/device/input.py @@ -1,3 +1,8 @@ +"""设备文本输入模块。 + +封装 Android 设备端的文本输入功能,包括输入法窗口状态检测 +以及通过 uiautomator2 向安卓组件发送文本指令和确认操作。 +""" # 此文件专门用于处理设备端的文本输入功能。 # 封装了检查输入法窗口状态以及向安卓组件发送文本指令的逻辑。 from module.device.method.uiautomator_2 import Uiautomator2 @@ -5,11 +10,33 @@ class Input(Uiautomator2): + """设备文本输入处理器。 + + 通过 uiautomator2 实现文本输入功能,包括输入法状态检测 + 和带确认操作的文本输入。继承自 Uiautomator2 以获取底层输入接口。 + + Methods: + ime_shown: 检测输入法窗口是否显示。 + text_input_and_confirm: 输入文本并发送确认动作。 + """ def ime_shown(self) -> bool: + """检测当前输入法(IME)窗口是否正在显示。 + + Returns: + bool: 输入法窗口可见返回 True,否则返回 False。 + """ _, shown = self.u2_current_ime() return shown def text_input_and_confirm(self, text: str, clear: bool=False): + """向当前焦点输入框发送文本并按确认键(IME_ACTION_DONE)。 + + 失败时最多重试 3 次,适用于输入法偶尔无响应的场景。 + + Args: + text (str): 要输入的文本内容。 + clear (bool): 输入前是否清空输入框已有内容。 + """ for fail_count in range(3): try: self.u2_send_keys(text=text, clear=clear) diff --git a/module/device/method/adb.py b/module/device/method/adb.py index d5a7bbe39..248a00df5 100644 --- a/module/device/method/adb.py +++ b/module/device/method/adb.py @@ -1,3 +1,12 @@ +""" +ADB 截图和输入方法。 + +通过 Android Debug Bridge (ADB) 执行设备截图和触控操作。 +主要提供截图捕获(`screenshot_adb`)、XML 层级获取(`dump_hierarchy`)等方法。 +基于 `adb exec-out screencap -p` 命令捕获屏幕图像, +通过 `adb shell input` 命令执行点击、滑动等触控操作。 +包含自动重试机制,处理 ADB 连接中断和图像截断等异常情况。 +""" import re import time from functools import wraps @@ -78,9 +87,9 @@ def init(): 'screenshot_adb', 'screenshot_adb_nc', '_app_start_adb_am', '_app_start_adb_monkey', ]: - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-ADB] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-ADB] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -169,7 +178,7 @@ def __process_screenshot(self, screenshot): self.__screenshot_method_fixed = self.__screenshot_method if len(screenshot) < 500: - logger.warning(f'Unexpected screenshot: {screenshot}') + logger.warning(f'[设备-ADB] 异常截图: {screenshot}') raise OSError(f'cannot load screenshot') @retry @@ -177,7 +186,7 @@ def __process_screenshot(self, screenshot): def screenshot_adb(self): data = self.adb_shell(['screencap', '-p'], stream=True) if len(data) < 500: - logger.warning(f'Unexpected screenshot: {data}') + logger.warning(f'[设备-ADB] 异常截图: {data}') return self.__process_screenshot(data) @@ -187,7 +196,7 @@ def screenshot_adb(self): data = self.adb_shell(['screencap'], stream=True) data = remove_screenshot_warning(data) if len(data) < 500: - logger.warning(f'Unexpected screenshot: {data}') + logger.warning(f'[设备-ADB] 异常截图: {data}') return load_screencap(data) @@ -196,7 +205,7 @@ def screenshot_adb_nc(self): data = self.adb_shell_nc(['screencap']) data = remove_screenshot_warning(data) if len(data) < 500: - logger.warning(f'Unexpected screenshot: {data}') + logger.warning(f'[设备-ADB] 异常截图: {data}') return load_screencap(data) @@ -319,7 +328,7 @@ def _app_start_adb_am(self, package_name=None, activity_name=None, allow_failure try: activity_name = activity_name.split('/')[-1] except IndexError: - logger.error(f'No activity name from {activity_name}') + logger.error(f'无Activity名称 {activity_name}') return False else: if allow_failure: @@ -346,7 +355,7 @@ def _app_start_adb_am(self, package_name=None, activity_name=None, allow_failure # 已在运行 # Warning: Activity not started, intent has been delivered to currently running top-most instance. if 'Warning: Activity not started' in ret: - logger.info('App activity is already started') + logger.info('应用Activity已启动') return True # 权限拒绝 # Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } @@ -356,7 +365,7 @@ def _app_start_adb_am(self, package_name=None, activity_name=None, allow_failure return False else: logger.error(ret) - logger.error('Permission Denial while starting app, probably because activity invalid') + logger.error('[设备-ADB] Permission Denial while starting app, probably because activity invalid') return False # 启动成功 # Starting: Intent... @@ -393,7 +402,7 @@ def app_start_adb(self, package_name=None, activity_name=None, allow_failure=Fal if self._app_start_adb_am(package_name, activity_name, allow_failure): return True - logger.error('app_start_adb: All trials failed') + logger.error('所有尝试失败') return False @retry diff --git a/module/device/method/ascreencap.py b/module/device/method/ascreencap.py index a6d2616fd..ee3e83d35 100644 --- a/module/device/method/ascreencap.py +++ b/module/device/method/ascreencap.py @@ -1,3 +1,11 @@ +""" +aScreenCap 截图方法。 + +通过 aScreenCap 工具执行设备截图,是标准 `screencap` 命令的高性能替代方案。 +aScreenCap 直接读取 Android 设备的 framebuffer,绕过系统 screencap 的额外处理, +截图速度更快、内存占用更低。支持原始压缩格式和 JPEG 编码两种模式。 +需要先通过 ADB 将 aScreenCap 推送至设备并赋予执行权限。 +""" import os import time from functools import wraps @@ -74,9 +82,9 @@ def init(): pass if func.__name__ in ['screenshot_ascreencap', 'screenshot_ascreencap_nc']: - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-aScreenCap] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-aScreenCap] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -89,13 +97,13 @@ class AScreenCap(Connection): ascreencap_available = True def ascreencap_init(self): - logger.hr('aScreenCap init') + logger.hr('[设备-aScreenCap] aScreenCap初始化') self.__bytepointer = 0 self.ascreencap_available = True arc = self.cpu_abi sdk = self.sdk_ver - logger.info(f'cpu_arc: {arc}, sdk_ver: {sdk}') + logger.info(f'[设备-aScreenCap] cpu_arc: {arc}, sdk_ver: {sdk}') if sdk in range(21, 26): ver = "Android_5.x-7.x" @@ -108,19 +116,17 @@ def ascreencap_init(self): filepath = os.path.join(self.config.ASCREENCAP_FILEPATH_LOCAL, ver, arc, 'ascreencap') if not os.path.exists(filepath): self.ascreencap_available = False - logger.error('No suitable version of aScreenCap lib available for this device, ' - 'please use other screenshot methods instead') - logger.error('[Device] 该设备没有可用的 aScreenCap 库,请使用其他截图方案。') + logger.error('[设备-aScreenCap] 该设备没有可用的 aScreenCap 库,请使用其他截图方案') raise RequestHumanTakeover - logger.info(f'pushing {filepath}') + logger.info(f'[设备-aScreenCap] 推送 {filepath}') self.adb_push(filepath, self.config.ASCREENCAP_FILEPATH_REMOTE) - logger.info(f'chmod 0777 {self.config.ASCREENCAP_FILEPATH_REMOTE}') + logger.info(f'[设备-aScreenCap] chmod 0777 {self.config.ASCREENCAP_FILEPATH_REMOTE}') self.adb_shell(['chmod', '0777', self.config.ASCREENCAP_FILEPATH_REMOTE]) def uninstall_ascreencap(self): - logger.info('Removing ascreencap') + logger.info('[设备-aScreenCap] 移除ascreencap') self.adb_shell(['rm', self.config.ASCREENCAP_FILEPATH_REMOTE]) def _ascreencap_reposition_byte_pointer(self, byte_array): @@ -134,7 +140,7 @@ def _ascreencap_reposition_byte_pointer(self, byte_array): text = 'Repositioning byte pointer failed, corrupted aScreenCap data received' logger.warning(text) if len(byte_array) < 500: - logger.warning(f'Unexpected screenshot: {byte_array}') + logger.warning(f'异常截图: {byte_array}') raise AscreencapError(text) return byte_array[self.__bytepointer:] @@ -156,7 +162,7 @@ def __uncompress(self, screenshot): text = 'aScreenCap returned incomplete data or empty payload' logger.warning(text) if raw_compressed_data is not None and len(raw_compressed_data) < 500: - logger.warning(f'Unexpected screenshot: {raw_compressed_data}') + logger.warning(f'异常截图: {raw_compressed_data}') raise AscreencapError(text) # 头部格式参考: @@ -215,7 +221,7 @@ def __process_screenshot(self, screenshot): self.__screenshot_method_fixed = self.__screenshot_method if len(screenshot) < 500: - logger.warning(f'Unexpected screenshot: {screenshot}') + logger.warning(f'异常截图: {screenshot}') raise ImageTruncated(f'cannot load screenshot') @retry @@ -228,6 +234,6 @@ def screenshot_ascreencap(self): def screenshot_ascreencap_nc(self): data = self.adb_shell_nc([self.config.ASCREENCAP_FILEPATH_REMOTE, '--pack', '2', '--stdout']) if len(data) < 500: - logger.warning(f'Unexpected screenshot: {data}') + logger.warning(f'异常截图: {data}') return self.__uncompress(data) diff --git a/module/device/method/droidcast.py b/module/device/method/droidcast.py index 7c7895eff..4eccf7740 100644 --- a/module/device/method/droidcast.py +++ b/module/device/method/droidcast.py @@ -1,3 +1,11 @@ +""" +DroidCast 截图方法。 + +通过 DroidCast 投屏服务执行设备截图,适用于 ADB screencap 不可用的场景。 +DroidCast 是一个运行在 Android 设备上的截图服务,通过 HTTP 接口提供屏幕图像。 +支持 DroidCast 和 DroidCast_raw 两种模式:前者返回 PNG/JPEG 图像, +后者直接返回原始像素数据以获得更高性能。需要先在设备上安装并启动 DroidCast APK。 +""" import time import typing as t from functools import wraps @@ -92,9 +100,9 @@ def init(): pass if func.__name__ in ['screenshot_droidcast', 'screenshot_droidcast_raw']: - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-DroidCast] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-DroidCast] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -154,14 +162,14 @@ def droidcast_raw_url(self, url='/screenshot'): return f'http://127.0.0.1:{self._droidcast_port}{url}' def droidcast_init(self): - logger.hr('DroidCast init') + logger.hr('[设备-DroidCast] DroidCast初始化') self.droidcast_stop() self._droidcast_update_resolution() - logger.info('Pushing DroidCast apk') + logger.info('[设备-DroidCast] 推送DroidCast APK') self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE) - logger.info('Starting DroidCast apk') + logger.info('[设备-DroidCast] 启动DroidCast APK') # DroidCast_raw-release-1.1.apk # CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main > /dev/null # adb shell CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main @@ -178,23 +186,23 @@ def droidcast_init(self): _ = self.droidcast_session if self.config.DROIDCAST_VERSION == 'DroidCast': - logger.attr('DroidCast', self.droidcast_url()) + logger.attr('DroidCast地址', self.droidcast_url()) self.droidcast_wait_startup() elif self.config.DROIDCAST_VERSION == 'DroidCast_raw': - logger.attr('DroidCast_raw', self.droidcast_raw_url()) + logger.attr('DroidCast原始地址', self.droidcast_raw_url()) self.droidcast_wait_startup() else: - logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}') + logger.error(f'未知的DROIDCAST版本: {self.config.DROIDCAST_VERSION}') def _droidcast_update_resolution(self): if self.is_mumu_over_version_356: - logger.info('Update droidcast resolution') + logger.info('[设备-DroidCast] 更新DroidCast分辨率') w, h = self.resolution_uiautomator2(cal_rotation=False) self.get_orientation() # 720, 1280 # mumu12 > 3.5.6 始终为竖屏设备 self.droidcast_width, self.droidcast_height = w, h - logger.info(f'Droicast resolution: {(w, h)}') + logger.info(f'DroidCast分辨率: {(w, h)}') @retry def screenshot_droidcast(self): @@ -214,7 +222,7 @@ def screenshot_droidcast(self): if image.shape == (1843200,): raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`') if image.size < 500: - logger.warning(f'Unexpected screenshot: {resp.content}') + logger.warning(f'[设备-DroidCast] 异常截图: {resp.content}') image = cv2.imdecode(image, cv2.IMREAD_COLOR) if image is None: @@ -254,7 +262,7 @@ def screenshot_droidcast_raw(self): # 例如 b':( Failed to generate the screenshot on device / emulator: ...' # 抛出 ConnectionError 以在重试处理器中立即触发 droidcast_init if len(image) < 500: - logger.warning(f'Unexpected screenshot: {image}') + logger.warning(f'[设备-DroidCast] 异常截图: {image}') raise requests.exceptions.ConnectionError(f'DroidCast service error: {image!r}') try: @@ -319,12 +327,12 @@ def droidcast_wait_startup(self): resp = self.droidcast_session.get(self.droidcast_url('/'), timeout=3) # 路由 `/` 不可用,但 404 表示启动已完成 if resp.status_code == 404: - logger.attr('DroidCast', 'online') + logger.attr('DroidCast状态', '在线') return True except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): - logger.attr('DroidCast', 'offline') + logger.attr('DroidCast状态', '离线') - logger.warning('Wait DroidCast startup timeout, assume started') + logger.warning('[设备-DroidCast] DroidCast启动超时,假定已启动') return False def droidcast_uninstall(self): @@ -333,7 +341,7 @@ def droidcast_uninstall(self): DroidCast 并非真正安装,而是通过 JAVA 类调用,卸载即删除文件。 """ self.droidcast_stop() - logger.info('Removing DroidCast') + logger.info('[设备-DroidCast] 移除DroidCast') self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE]) def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]: @@ -349,7 +357,7 @@ def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]: def droidcast_stop(self): """停止 DroidCast 进程。""" - logger.info('Stopping DroidCast') + logger.info('[设备-DroidCast] 停止DroidCast') for proc in self._iter_droidcast_proc(): - logger.info(f'Kill pid={proc.pid}') + logger.info(f'[设备-DroidCast] 终止进程PID={proc.pid}') self.adb_shell(['kill', '-s', 9, proc.pid]) diff --git a/module/device/method/hermit.py b/module/device/method/hermit.py index 0bdb5a774..0a617f368 100644 --- a/module/device/method/hermit.py +++ b/module/device/method/hermit.py @@ -1,3 +1,6 @@ +"""Hermit 截图和控制后端。通过 HTTP 与 ADB 端口转发通信, +提供基于 JSON 协议的截图捕获和触摸注入功能。""" + import json import time from functools import wraps @@ -83,7 +86,7 @@ def init(): def init(): pass - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-Hermit] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -109,18 +112,18 @@ def _hermit_url(self): return f'http://127.0.0.1:{self._hermit_port}' def hermit_init(self): - logger.hr('Hermit init') + logger.hr('[设备-Hermit] Hermit初始化') self.app_stop_adb(self._hermit_package_name) # self.uninstall_hermit() - logger.info('Try to start hermit') + logger.info('[设备-Hermit] 尝试启动Hermit') if self.app_start_adb(self._hermit_package_name, allow_failure=True): # 成功启动 hermit - logger.info('Success to start hermit') + logger.info('[设备-Hermit] 启动Hermit成功') else: # Hermit 未安装 - logger.warning(f'{self._hermit_package_name} not found, installing hermit') + logger.warning(f'[设备-Hermit] {self._hermit_package_name} 未找到,正在安装 hermit') self.adb_command(['install', '-t', self.config.HERMIT_FILEPATH_LOCAL]) self.app_start_adb(self._hermit_package_name) @@ -150,7 +153,7 @@ def hermit_enable_accessibility(self): Raises: RequestHumanTakeover: 失败时抛出,需要用户手动操作。 """ - logger.hr('Enable accessibility service') + logger.hr('启用无障碍服务') interval = Timer(0.3) timeout = Timer(10, count=10).start() while 1: @@ -165,7 +168,7 @@ def appear_then_click(xpath): b = HierarchyButton(h, xpath) if b: point = random_rectangle_point(b.button) - logger.info(f'Click {point2str(*point)} @ {b}') + logger.info(f'[设备-Hermit] 点击 {point2str(*point)} @ {b}') self.click_adb(*point) return True else: @@ -185,10 +188,10 @@ def appear_then_click(xpath): # 超时 if timeout.reached(): - logger.critical('[Device] 无法为 Hermit 打开辅助功能服务') + logger.critical('[设备-Hermit] 无法为 Hermit 打开辅助功能服务') logger.critical( '\n\n' - '[Device] 请手动执行以下操作:\n' + '[设备-Hermit] 请手动执行以下操作:\n' '1. 在辅助功能设置中找到 "Hermit" 并点击\n' '2. 将其打开并点击 "确定"\n' '3. 切换回碧蓝航线\n' @@ -223,7 +226,7 @@ def hermit_send(self, url, **kwargs): e = HermitError(result) if 'GestureDescription$Builder' in result: logger.error(e) - logger.critical('[Device] Hermit 无法在当前设备上运行,Hermit 需要 Android>=7.0') + logger.critical('[设备-Hermit] Hermit 无法在当前设备上运行,Hermit 需要 Android>=7.0') raise RequestHumanTakeover if 'accessibilityservice' in result: # 尝试调用虚拟方法 @@ -232,7 +235,7 @@ def hermit_send(self, url, **kwargs): # android.accessibilityservice.AccessibilityService$GestureResultCallback, # android.os.Handler # )' on a null object reference - logger.error('Unable to access accessibility service') + logger.error('[设备-Hermit] 无法访问无障碍服务') raise e # Hermit 请求仅需 2-4ms diff --git a/module/device/method/ldopengl.py b/module/device/method/ldopengl.py index 7db5b3d00..a177de30d 100644 --- a/module/device/method/ldopengl.py +++ b/module/device/method/ldopengl.py @@ -1,3 +1,6 @@ +"""雷电模拟器 OpenGL 截图后端。通过 LDPlayer 的原生 OpenGL 接口 +直接读取渲染缓冲区,实现低延迟高质量截图。""" + import ctypes import os import subprocess @@ -86,19 +89,19 @@ def subprocess_run(self, cmd, timeout=10): bytes: """ cmd = [self.ld_console] + cmd - logger.info(f'Execute: {cmd}') + logger.info(f'执行: {cmd}') try: process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False) except FileNotFoundError as e: - logger.warning(f'warning when calling {cmd}, {str(e)}') + logger.warning(f'调用时警告 {cmd}, {str(e)}') raise LDOpenGLIncompatible(f'ld_folder does not have ldconsole.exe') try: stdout, stderr = process.communicate(timeout=timeout) except subprocess.TimeoutExpired: process.kill() stdout, stderr = process.communicate() - logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}') + logger.warning(f'调用超时 {cmd}, stdout={stdout}, stderr={stderr}') return stdout def list2(self): @@ -119,13 +122,13 @@ def list2(self): info = row.split(b',') # 检查字段数 if len(info) != 10: - logger.warning(f'ldplayer info does not have 10 parts: "{row}"') + logger.warning(f'雷电模拟器信息不足10部分: "{row}"') continue # 构建信息 try: info = DataLDPlayerInfo(*info) except Exception as e: - logger.warning(f'Failed to build ldplayer info from "{row}", {e}') + logger.warning(f'无法构建雷电模拟器信息 "{row}", {e}') out.append(info) return out @@ -182,7 +185,7 @@ def init(): def init(): pass - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-ldopengl] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -197,7 +200,7 @@ def __init__(self, ld_folder: str, instance_id: int): """ ldopengl_dll = os.path.abspath(os.path.join(ld_folder, './ldopengl64.dll')) logger.info( - f'LDOpenGL init, ' + f'[设备-ldopengl] LDOpenGL init, ' f'ld_folder={ld_folder}, ' f'ldopengl_dll={ldopengl_dll}, ' f'instance_id={instance_id}' @@ -240,7 +243,7 @@ def get_player_info_by_index(self, instance_id: int): """ for info in self.console.list2(): if info.index == instance_id: - logger.info(f'Match LDPlayer instance: {info}') + logger.info(f'[设备-ldopengl] 匹配雷电模拟器实例: {info}') if not info.sysboot: raise LDOpenGLError('尝试连接雷电模拟器实例,但模拟器未运行') return info @@ -307,13 +310,13 @@ def ldopengl(self): ) except (LDOpenGLIncompatible, LDOpenGLError) as e: logger.error(e) - logger.error('[Device] 模拟器信息不正确') + logger.error('[设备-ldopengl] 模拟器信息不正确') # 搜索模拟器实例 # 例如 E:/ProgramFiles/LDPlayer9/dnplayer.exe # 安装路径为 E:/ProgramFiles/LDPlayer9 if self.emulator_instance is None: - logger.error('[Device] 无法使用 LDOpenGL,因为未找到模拟器实例') + logger.error('[设备-ldopengl] 无法使用 LDOpenGL,因为未找到模拟器实例') raise RequestHumanTakeover try: return LDOpenGLImpl( @@ -322,7 +325,7 @@ def ldopengl(self): ) except (LDOpenGLIncompatible, LDOpenGLError) as e: logger.error(e) - logger.error('[Device] 无法初始化 LDOpenGL') + logger.error('[设备-ldopengl] 无法初始化 LDOpenGL') raise RequestHumanTakeover def ldopengl_available(self) -> bool: @@ -330,7 +333,7 @@ def ldopengl_available(self) -> bool: return False if not self.is_ldplayer_bluestacks_family: return False - logger.attr('EmulatorInfo_Emulator', self.config.EmulatorInfo_Emulator) + logger.attr('模拟器信息', self.config.EmulatorInfo_Emulator) if self.config.EmulatorInfo_Emulator not in ['LDPlayer9', 'LDPlayer14']: return False diff --git a/module/device/method/maatouch.py b/module/device/method/maatouch.py index adb77c21d..25c40f204 100644 --- a/module/device/method/maatouch.py +++ b/module/device/method/maatouch.py @@ -1,3 +1,12 @@ +""" +MaaTouch 触控输入方法。 + +基于 MaaTouch 工具实现高性能的设备触控操作。 +MaaTouch 是 minitouch 的增强替代方案,通过 WebSocket 协议与设备通信, +支持更高的触控采样率和更稳定的连接。提供点击、长按、滑动等触控操作, +滑动使用贝塞尔曲线插值生成自然轨迹。兼容 minitouch 的命令格式, +通过 ADB 端口转发建立 WebSocket 连接。 +""" import socket import threading import time @@ -190,12 +199,12 @@ def on_orientation_change_maatouch(self): if self.orientation == self._maatouch_orientation: return - logger.info(f'Orientation changed {self._maatouch_orientation} => {self.orientation}, re-init MaaTouch') + logger.info(f'方向已变更 {self._maatouch_orientation} => {self.orientation}, re-init MaaTouch') del_cached_property(self, '_maatouch_builder') self.early_maatouch_init() def maatouch_init(self): - logger.hr('MaaTouch init') + logger.hr('[设备-MaaTouch] 初始化') max_x, max_y = 1280, 720 max_contacts = 2 max_pressure = 50 @@ -334,11 +343,11 @@ def maatouch_send_sync(self, builder: MaatouchBuilder, mode=2): builder.clear() def maatouch_install(self): - logger.hr('MaaTouch install') + logger.hr('[设备-MaaTouch] 安装') self.adb_push(self.config.MAATOUCH_FILEPATH_LOCAL, self.config.MAATOUCH_FILEPATH_REMOTE) def maatouch_uninstall(self): - logger.hr('MaaTouch uninstall') + logger.hr('[设备-MaaTouch] 卸载') self.adb_shell(["rm", self.config.MAATOUCH_FILEPATH_REMOTE]) @retry diff --git a/module/device/method/minitouch.py b/module/device/method/minitouch.py index f0b8e1475..060a55e88 100644 --- a/module/device/method/minitouch.py +++ b/module/device/method/minitouch.py @@ -1,3 +1,12 @@ +""" +minitouch 触控输入方法。 + +基于 minitouch 工具实现低延迟的设备触控操作。 +minitouch 通过 Unix Socket 直接向 Android 设备的输入子系统发送触控事件, +比 `adb shell input` 命令更快、更精确。支持点击、长按、滑动和多点触控。 +使用正态分布随机化触控坐标和速度,模拟自然的用户操作行为。 +需要先通过 ADB 将 minitouch 推送至设备并建立 Socket 连接。 +""" import asyncio import json import socket @@ -351,7 +360,7 @@ def _check_empty(self, text=None): empty = False break if empty: - logger.warning(f'Command list empty, sending it may cause unexpected behaviour: {text}') + logger.warning(f'命令列表为空,发送可能导致异常行为: {text}') return empty @@ -456,10 +465,10 @@ def init(): pass if func.__name__ in ['_minitouch_builder']: - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-MiniTouch] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-MiniTouch] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -507,7 +516,7 @@ def early_minitouch_init_func(): @Config.when(DEVICE_OVER_HTTP=False) def minitouch_init(self): - logger.hr('MiniTouch init') + logger.hr('[设备-MiniTouch] MiniTouch初始化') max_x, max_y = 1280, 720 max_contacts = 2 max_pressure = 50 @@ -579,10 +588,10 @@ def minitouch_init(self): self._minitouch_pid = pid logger.info( - "minitouch running on port: {}, pid: {}".format(self._minitouch_port, self._minitouch_pid) + "[设备-MiniTouch] minitouch 运行端口: {}, pid: {}".format(self._minitouch_port, self._minitouch_pid) ) logger.info( - "max_contact: {}; max_x: {}; max_y: {}; max_pressure: {}".format( + "[设备-MiniTouch] max_contact: {}; max_x: {}; max_y: {}; max_pressure: {}".format( max_contacts, max_x, max_y, max_pressure ) ) @@ -624,11 +633,11 @@ def _minitouch_loop_run(self, event): @Config.when(DEVICE_OVER_HTTP=True) def minitouch_init(self): - logger.hr('MiniTouch init') + logger.hr('[设备-MiniTouch] MiniTouch初始化') self.max_x, self.max_y = 1280, 720 self.get_orientation() - logger.info('Stop minitouch service') + logger.info('[设备-MiniTouch] 停止minitouch服务') s = U2Service('minitouch', self.u2) s.stop() while 1: @@ -636,7 +645,7 @@ def minitouch_init(self): break self.sleep(0.05) - logger.info('Start minitouch service') + logger.info('[设备-MiniTouch] 启动minitouch服务') s.start() while 1: if s.running(): @@ -645,7 +654,7 @@ def minitouch_init(self): # 'ws://127.0.0.1:7912/minitouch' url = re.sub(r"^https?://", 'ws://', self.serial) + '/minitouch' - logger.attr('Minitouch', url) + logger.attr('MiniTouch地址', url) async def connect(): ws = await websockets.connect(url) diff --git a/module/device/method/nemu_ipc.py b/module/device/method/nemu_ipc.py index 0cd11b3b7..7b500b853 100644 --- a/module/device/method/nemu_ipc.py +++ b/module/device/method/nemu_ipc.py @@ -1,3 +1,11 @@ +""" +MuMu 模拟器 IPC 通信方法。 + +通过进程间通信 (IPC) 直接与 MuMu 模拟器交互,实现高性能截图和触控操作。 +使用 ctypes 加载 MuMu 的 nemu_ipc DLL,绕过 ADB 层直接调用模拟器内部接口, +截图延迟极低且无需网络传输。支持截图、点击、滑动和按键操作。 +仅限 Windows 平台使用,需要 MuMu 模拟器支持 IPC 接口。 +""" import ctypes import json import os @@ -128,12 +136,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): def check_stdout(self): if not self.stdout: return - logger.info(f'NemuIpc stdout: {self.stdout}') + logger.info(f'[设备-NemuIpc] NemuIpc标准输出: {self.stdout}') def check_stderr(self): if not self.stderr: return - logger.error(f'NemuIpc stderr: {self.stderr}') + logger.error(f'[设备-NemuIpc] NemuIpc标准错误: {self.stderr}') # 调用了旧版本的 MuMu12 # 在 3.4.0 上测试 @@ -184,7 +192,7 @@ def retry_wrapper(self, *args, **kwargs): break # 函数调用超时 except JobTimeout: - logger.warning(f'Func {func.__name__}() call timeout, retrying: {_}') + logger.warning(f'Func {func.__name__}() 调用超时,重试: {_}') def init(): pass @@ -205,10 +213,10 @@ def init(): pass if func.__name__ in ['connect_with_retry', 'screenshot', 'down', 'up']: - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-NemuIpc] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'[Device] 重试 {func.__name__}() 失败') + logger.critical(f'[设备-NemuIpc] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -244,7 +252,7 @@ def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0): break except OSError as e: logger.error(e) - logger.error(f'ipc_dll={ipc_dll} exists, but cannot be loaded') + logger.error(f'ipc_dll={ipc_dll} 存在,但无法加载') continue if self.lib is None: # 未找到 @@ -253,7 +261,7 @@ def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0): f'None of the following path exists: {list_dll}') # 成功 logger.info( - f'NemuIpcImpl init, ' + f'[设备-NemuIpc] NemuIpcImpl init, ' f'nemu_folder={nemu_folder}, ' f'ipc_dll={ipc_dll}, ' f'instance_id={instance_id}, ' @@ -341,7 +349,7 @@ def run_func(func, *args, on_thread=True, timeout=0.5): err = True # 获取标准输出中实际的错误信息 if err: - logger.warning(f'Failed to call {func.__name__}, result={result}') + logger.warning(f'调用失败 {func.__name__}, result={result}') with CaptureNemuIpc(): func(*args) @@ -498,16 +506,16 @@ def nemu_ipc(self) -> NemuIpcImpl: ).__enter__() except (NemuIpcIncompatible, NemuIpcError, JobTimeout) as e: logger.error(e) - logger.error('Emulator info incorrect') + logger.error('[设备-NemuIpc] 模拟器信息不正确') # 搜索模拟器实例 # 例如 E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe # 安装路径为 E:\ProgramFiles\MuMuPlayer-12.0 if self.emulator_instance is None: - logger.error('[Device] 无法使用 NemuIpc,因为未找到模拟器实例') + logger.error('[设备-NemuIpc] 无法使用 NemuIpc,因为未找到模拟器实例') raise RequestHumanTakeover if 'MuMuPlayerGlobal' in self.emulator_instance.path: - logger.info(f'[Device] nemu_ipc 在 MuMuPlayerGlobal 上不可用, {self.emulator_instance.path}') + logger.info(f'[设备-NemuIpc] nemu_ipc 在 MuMuPlayerGlobal 上不可用, {self.emulator_instance.path}') raise RequestHumanTakeover try: impl = NemuIpcImpl( @@ -519,7 +527,7 @@ def nemu_ipc(self) -> NemuIpcImpl: return impl except (NemuIpcIncompatible, NemuIpcError, JobTimeout) as e: logger.error(e) - logger.error('Unable to initialize NemuIpc') + logger.error('[设备-NemuIpc] 无法初始化NemuIpc') raise RequestHumanTakeover def nemu_ipc_available(self) -> bool: @@ -560,14 +568,13 @@ def check_mumu_app_keep_alive_400(file): s = f.read() data = json.loads(s) except FileNotFoundError: - logger.warning(f'Failed to check check_mumu_app_keep_alive, file {file} not exists') + logger.warning(f'[设备-NemuIpc] 检查 check_mumu_app_keep_alive 失败,文件 {file} 不存在') return False value = deep_get(data, keys='customer.app_keptlive', default=None) logger.attr('customer.app_keptlive', value) if str(value).lower() == 'true': # https://mumu.163.com/help/20230802/35047_1102450.html - logger.critical('Please turn off "Keep alive in the background" in the settings or MuMuPlayer') - logger.critical('[Device] 请在MuMu模拟器设置内关闭 "后台挂机时保活运行"') + logger.critical('[设备-NemuIpc] 请在MuMu模拟器设置内关闭 "后台挂机时保活运行"') raise RequestHumanTakeover return True @@ -586,7 +593,7 @@ def check_mumu_app_keep_alive(self): # 搜索模拟器实例 if self.emulator_instance is None: - logger.warning('[Device] 检查 check_mumu_app_keep_alive 失败,因为 emulator_instance 为 None') + logger.warning('[设备-NemuIpc] 检查 check_mumu_app_keep_alive 失败,因为 emulator_instance 为 None') return False name = self.emulator_instance.name file = self.emulator_instance.mumu_vms_config('customer_config.json') @@ -599,7 +606,7 @@ def nemu_ipc_release(self): if has_cached_property(self, 'nemu_ipc'): self.nemu_ipc.disconnect() del_cached_property(self, 'nemu_ipc') - logger.info('nemu_ipc released') + logger.info('[设备-NemuIpc] nemu_ipc已释放') def screenshot_nemu_ipc(self): image = self.nemu_ipc.screenshot() diff --git a/module/device/method/pool.py b/module/device/method/pool.py index f8b8fae3b..41fcdd569 100644 --- a/module/device/method/pool.py +++ b/module/device/method/pool.py @@ -1,3 +1,6 @@ +"""工作线程池。基于生产者-消费者模型的通用任务池, +用于并发执行截图、控制等设备操作,支持优雅关闭和异常传播。""" + import abc import ctypes import subprocess @@ -403,7 +406,7 @@ def _subprocess_execute(cmd, timeout=10): Returns: bytes: """ - logger.info(f'Execute: {cmd}') + logger.info(f'[设备-进程池] 执行: {cmd}') process = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=False) @@ -412,7 +415,7 @@ def _subprocess_execute(cmd, timeout=10): except subprocess.TimeoutExpired: process.kill() stdout, stderr = process.communicate() - logger.warning(f'TimeoutExpired when calling {cmd}, stdout={stdout}, stderr={stderr}') + logger.warning(f'[设备-进程池] 调用超时: {cmd},标准输出={stdout},标准错误={stderr}') return stdout def start_cmd_soon(self, cmd, timeout=10): diff --git a/module/device/method/remove_warning.py b/module/device/method/remove_warning.py index 3e07135c7..2faa7c0b9 100644 --- a/module/device/method/remove_warning.py +++ b/module/device/method/remove_warning.py @@ -1,3 +1,6 @@ +"""ADB Shell 警告信息过滤。移除 VMOS 等模拟器 Shell 输出中的 +链接器警告信息,避免干扰后续数据解析(如 PNG 截图数据)。""" + from typing import overload diff --git a/module/device/method/scrcpy/__init__.py b/module/device/method/scrcpy/__init__.py index f4b30354b..f8c2026cb 100644 --- a/module/device/method/scrcpy/__init__.py +++ b/module/device/method/scrcpy/__init__.py @@ -1 +1,3 @@ +"""Scrcpy 截图和控制后端包。""" + from .scrcpy import Scrcpy, ScrcpyError diff --git a/module/device/method/scrcpy/control.py b/module/device/method/scrcpy/control.py index 3e64314d1..e080040be 100644 --- a/module/device/method/scrcpy/control.py +++ b/module/device/method/scrcpy/control.py @@ -1,3 +1,6 @@ +"""Scrcpy 控制指令发送器。封装触摸、按键等输入事件的 +二进制编码和 Socket 发送,通过 inject 装饰器实现协议注入。""" + import functools import socket import struct diff --git a/module/device/method/scrcpy/core.py b/module/device/method/scrcpy/core.py index 499338b36..f50c46af2 100644 --- a/module/device/method/scrcpy/core.py +++ b/module/device/method/scrcpy/core.py @@ -1,3 +1,6 @@ +"""Scrcpy 核心连接层。管理与设备端 scrcpy 服务的 Socket 连接, +处理视频流解码、控制通道建立和连接生命周期。""" + import socket import struct import threading @@ -47,8 +50,8 @@ def _scrcpy_control(self) -> ControlSender: def scrcpy_init(self): self._scrcpy_server_stop() - logger.hr('Scrcpy init') - logger.info(f'pushing {self.config.SCRCPY_FILEPATH_LOCAL}') + logger.hr('[设备-Scrcpy] Scrcpy初始化') + logger.info(f'[设备-Scrcpy] 推送中 {self.config.SCRCPY_FILEPATH_LOCAL}') self.adb_push(self.config.SCRCPY_FILEPATH_LOCAL, self.config.SCRCPY_FILEPATH_REMOTE) self._scrcpy_alive = False @@ -68,7 +71,7 @@ def _scrcpy_server_start(self): adbutils.AdbTimeout: socket.timeout: """ - logger.hr('Scrcpy server start') + logger.hr('[设备-Scrcpy] Scrcpy服务器启动') commands = ScrcpyOptions.command_v120(jar_path=self.config.SCRCPY_FILEPATH_REMOTE) self._scrcpy_server_stream: AdbConnection = self.adb.shell( commands, @@ -76,7 +79,7 @@ def _scrcpy_server_start(self): ) self._scrcpy_server_stream.conn.settimeout(3) - logger.info('Create server stream') + logger.info('[设备-Scrcpy] 创建服务器流') ret = self._scrcpy_server_stream.read(10) # b'Aborted \r\n' # Probably because file not exists @@ -97,7 +100,7 @@ def _scrcpy_server_start(self): logger.info(ret) pass - logger.info('Create video socket') + logger.info('[设备-Scrcpy] 创建视频Socket') timeout = Timer(3).start() while 1: if timeout.reached(): @@ -115,26 +118,26 @@ def _scrcpy_server_start(self): if not len(dummy_byte) or dummy_byte != b"\x00": raise ScrcpyError('Did not receive Dummy Byte from video stream') - logger.info('Create control socket') + logger.info('[设备-Scrcpy] 创建控制Socket') self._scrcpy_control_socket = self.adb.create_connection( Network.LOCAL_ABSTRACT, "scrcpy" ) self._scrcpy_control_socket.settimeout(3) - logger.info('Fetch device info') + logger.info('[设备-Scrcpy] 获取设备信息') device_name = self._scrcpy_video_socket.recv(64).decode("utf-8").rstrip("\x00") if len(device_name): - logger.attr('Scrcpy Device', device_name) + logger.attr('[设备-Scrcpy] 设备名称', device_name) else: raise ScrcpyError('Did not receive Device Name') ret = self._scrcpy_video_socket.recv(4) self._scrcpy_resolution = struct.unpack(">HH", ret) - logger.attr('Scrcpy Resolution', self._scrcpy_resolution) + logger.attr('[设备-Scrcpy] 分辨率', self._scrcpy_resolution) self._scrcpy_video_socket.setblocking(False) self._scrcpy_alive = True - logger.info('Start video stream loop thread') + logger.info('[设备-Scrcpy] 启动视频流循环线程') self._scrcpy_stream_loop_thread = threading.Thread( target=self._scrcpy_stream_loop, daemon=True ) @@ -144,13 +147,13 @@ def _scrcpy_server_start(self): break self.sleep(0.001) - logger.info('Scrcpy server is up') + logger.info('[设备-Scrcpy] Scrcpy服务器已启动') def _scrcpy_server_stop(self): """ Stop listening (both threaded and blocked) """ - logger.hr('Scrcpy server stop') + logger.hr('[设备-Scrcpy] Scrcpy服务器停止') # err = self._scrcpy_receive_from_server_stream() # if err: # logger.error(err) @@ -186,7 +189,7 @@ def _scrcpy_server_stop(self): del self._scrcpy_server_stream self._scrcpy_server_stream = None - logger.info('Scrcpy server stopped') + logger.info('[设备-Scrcpy] Scrcpy服务器已停止') def _scrcpy_receive_from_server_stream(self): if self._scrcpy_server_stream is not None: @@ -204,7 +207,7 @@ def _scrcpy_stream_loop(self) -> None: from av.error import InvalidDataError except ImportError as e: logger.error(e) - logger.error('You must have `av` installed to use scrcpy screenshot, please update dependencies') + logger.error('[设备-Scrcpy] 您必须安装 `av` 才能使用scrcpy截图,请更新依赖') raise RequestHumanTakeover codec = CodecContext.create("h264", "r") diff --git a/module/device/method/scrcpy/options.py b/module/device/method/scrcpy/options.py index 6f4375b44..e263fded2 100644 --- a/module/device/method/scrcpy/options.py +++ b/module/device/method/scrcpy/options.py @@ -1,3 +1,6 @@ +"""Scrcpy 配置选项。定义视频编码参数(帧率、比特率、H.264 Profile 等) +和屏幕方向等运行时配置。""" + import typing as t import module.device.method.scrcpy.const as const diff --git a/module/device/method/scrcpy/scrcpy.py b/module/device/method/scrcpy/scrcpy.py index 188bdf9c1..3515dbba4 100644 --- a/module/device/method/scrcpy/scrcpy.py +++ b/module/device/method/scrcpy/scrcpy.py @@ -1,3 +1,6 @@ +"""Scrcpy 截图和控制后端。整合 ScrcpyCore 和 ControlSender, +提供带重试的截图捕获、触摸点击和滑动操作。""" + import socket import time from functools import wraps @@ -79,9 +82,9 @@ def init(): pass if func.__name__ in ['screenshot_scrcpy']: - logger.critical(f'Retry {func.__name__}() failed') + logger.critical(f'[设备-Scrcpy] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'Retry {func.__name__}() failed') + logger.critical(f'[设备-Scrcpy] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper diff --git a/module/device/method/uiautomator_2.py b/module/device/method/uiautomator_2.py index 2019b61e5..15a2d743f 100644 --- a/module/device/method/uiautomator_2.py +++ b/module/device/method/uiautomator_2.py @@ -105,10 +105,10 @@ def init(): '_app_start_u2_am', '_app_start_u2_monkey', 'screenshot_uiautomator2', ]: - logger.critical(f'Retry {func.__name__}() failed') + logger.critical(f'[设备-U2] 重试 {func.__name__}() 失败') raise EmulatorNotRunningError - logger.critical(f'Retry {func.__name__}() failed') + logger.critical(f'[设备-U2] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper @@ -195,13 +195,13 @@ def _drag_along(self, path): x, y, second = data if index == 0: self.u2.touch.down(x, y) - logger.info(point2str(x, y) + ' down') + logger.info(point2str(x, y) + ' 按下') elif index - length == -1: self.u2.touch.up(x, y) - logger.info(point2str(x, y) + ' up') + logger.info(point2str(x, y) + ' 抬起') else: self.u2.touch.move(x, y) - logger.info(point2str(x, y) + ' move') + logger.info(point2str(x, y) + ' 移动') self.sleep(second) def drag_uiautomator2(self, p1, p2, segments=1, shake=(0, 15), point_random=(-10, -10, 10, 10), @@ -326,7 +326,7 @@ def _app_start_u2_am(self, package_name=None, activity_name=None, allow_failure= # 已在运行 # Warning: Activity not started, intent has been delivered to currently running top-most instance. if 'Warning: Activity not started' in ret.output: - logger.info('App activity is already started') + logger.info('应用Activity已启动') return True # Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } # java.lang.SecurityException: Permission Denial: starting Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } from null (pid=5140, uid=2000) not exported from uid 10064 @@ -344,7 +344,7 @@ def _app_start_u2_am(self, package_name=None, activity_name=None, allow_failure= return False else: logger.error(ret) - logger.error('Permission Denial while starting app, probably because activity invalid') + logger.error('启动应用时权限拒绝,可能Activity无效') return False # 成功 # Starting: Intent... @@ -383,7 +383,7 @@ def app_start_uiautomator2(self, package_name=None, activity_name=None, allow_fa if self._app_start_u2_am(package_name, activity_name, allow_failure): return True - logger.error('app_start_uiautomator2: All trials failed') + logger.error('[设备-U2] 所有尝试失败') return False @retry @@ -400,7 +400,7 @@ def dump_hierarchy_uiautomator2(self) -> etree._Element: return hierarchy def uninstall_uiautomator2(self): - logger.info('Removing uiautomator2') + logger.info('[设备-U2] 移除uiautomator2') for file in [ 'app-uiautomator.apk', 'app-uiautomator-test.apk', @@ -430,7 +430,7 @@ def resolution_uiautomator2(self, cal_rotation=True) -> t.Tuple[int, int]: result = self.adb_shell(['wm', 'size']) lines = result.strip().split('\n') except Exception: - logger.warning('Failed to execute adb shell wm size, fallback to u2 /info') + logger.warning('[设备-U2] 执行adb shell wm size失败,回退到u2 /info') if lines: w, h = None, None @@ -503,14 +503,14 @@ def resolution_check_uiautomator2(self): RequestHumanTakeover: 分辨率不是 1280x720 时抛出 """ width, height = self.resolution_uiautomator2() - logger.attr('Screen_size', f'{width}x{height}') + logger.attr('屏幕尺寸', f'{width}x{height}') if width == 1280 and height == 720: return (width, height) if width == 720 and height == 1280: return (width, height) - logger.critical(f"[Device] 大叔,你看着分辨率对吗: {width}x{height}。真是个连分辨率都不会设的杂鱼呢❤") - logger.critical("[Device] 乖乖给我改成 1280x720 哦,不然我可不理你了❤") + logger.critical(f"[设备-U2] 大叔,你看着分辨率对吗: {width}x{height}。真是个连分辨率都不会设的杂鱼呢❤") + logger.critical("[设备-U2] 乖乖给我改成 1280x720 哦,不然我可不理你了❤") raise RequestHumanTakeover @retry @@ -579,4 +579,4 @@ def clipboard(self): return self.u2.clipboard def set_clipboard(self, text, label=None): - return self.u2.set_clipboard(text=text, label=label) \ No newline at end of file + return self.u2.set_clipboard(text=text, label=label) diff --git a/module/device/method/utils.py b/module/device/method/utils.py index 9c1af5d36..28fc8ce2c 100644 --- a/module/device/method/utils.py +++ b/module/device/method/utils.py @@ -1,3 +1,6 @@ +"""设备方法层通用工具。包含 ADB 错误处理、重试策略、序列号解析、 +UI 层级解析(HierarchyButton)和 Shell 命令辅助函数。""" + import os import random import re @@ -79,14 +82,14 @@ def handle_image_truncated(obj, exc: Exception) -> None: """ serial = getattr(obj, 'serial', None) cnt = report_image_truncated(serial) - logger.error(f'ImageTruncated occurred ({cnt}) for device {serial}: {exc}') + logger.error(f'图像截断发生 ({cnt}) for device {serial}: {exc}') if cnt >= IMAGE_TRUNCATED_THRESHOLD: - logger.warning(f'ImageTruncated reached threshold ({IMAGE_TRUNCATED_THRESHOLD}) for {serial}, attempting recovery') + logger.warning(f'[设备-工具] 图像截断达到阈值 ({IMAGE_TRUNCATED_THRESHOLD}) 对于 {serial},尝试恢复') # Try specific recoveries in order of likelihood try: if hasattr(obj, 'droidcast_init'): - logger.info('Attempting to restart DroidCast service') + logger.info('尝试重启DroidCast服务') try: obj.droidcast_init() except Exception: @@ -225,7 +228,7 @@ def possible_reasons(*args): """ for index, reason in enumerate(args): index += 1 - logger.critical(f'Possible reason #{index}: {reason}') + logger.critical(f'[设备-工具] 可能原因 #{index}: {reason}') class PackageNotInstalled(Exception): diff --git a/module/device/method/wsa.py b/module/device/method/wsa.py index 6522c0110..c0f2e86aa 100644 --- a/module/device/method/wsa.py +++ b/module/device/method/wsa.py @@ -1,3 +1,6 @@ +"""WSA(Windows Subsystem for Android)截图和控制后端。 +继承 Connection,通过 ADB 连接 WSA 实例进行截图和操作。""" + import re import time from functools import wraps @@ -58,7 +61,7 @@ def init(): def init(): pass - logger.critical(f'Retry {func.__name__}() failed') + logger.critical(f'[设备-WSA] 重试 {func.__name__}() 失败') raise RequestHumanTakeover return retry_wrapper diff --git a/module/device/pkg_resources/__init__.py b/module/device/pkg_resources/__init__.py index 865afcbd1..6d38f4bed 100644 --- a/module/device/pkg_resources/__init__.py +++ b/module/device/pkg_resources/__init__.py @@ -1,3 +1,5 @@ +"""pkg_resources 兼容性补丁,避免导入 adbutils/uiautomator2 时的性能损失。""" + import os import re import sys @@ -22,7 +24,7 @@ try: sys.modules['pkg_resources'] = sys.modules['module.device.pkg_resources'] except KeyError: - logger.error('Patch pkg_resources failed, patch module does not exists') + logger.error('[设备-补丁] 修补pkg_resources失败,补丁模块不存在') def removesuffix(s, suffix): diff --git a/module/device/platform/__init__.py b/module/device/platform/__init__.py index 0f24a8143..e6819330c 100644 --- a/module/device/platform/__init__.py +++ b/module/device/platform/__init__.py @@ -1,3 +1,5 @@ +"""平台模拟器管理包。""" + from module.device.env import IS_WINDOWS, IS_MACINTOSH if IS_WINDOWS: diff --git a/module/device/platform/emulator_base.py b/module/device/platform/emulator_base.py index 8c00559e1..9e1b8d39c 100644 --- a/module/device/platform/emulator_base.py +++ b/module/device/platform/emulator_base.py @@ -1,3 +1,6 @@ +"""模拟器基类定义。提供 EmulatorBase、EmulatorInstanceBase、 +EmulatorManagerBase 的抽象接口,定义模拟器路径和实例管理的通用协议。""" + import os import re import typing as t diff --git a/module/device/platform/emulator_mac.py b/module/device/platform/emulator_mac.py index af1e77b5a..6ca6b6bce 100644 --- a/module/device/platform/emulator_mac.py +++ b/module/device/platform/emulator_mac.py @@ -1,3 +1,6 @@ +"""macOS 模拟器管理。实现 macOS 平台的模拟器实例检测和管理, +支持 BlueStacks Air 和 MuMu Pro 等模拟器。""" + import json import os import re diff --git a/module/device/platform/emulator_windows.py b/module/device/platform/emulator_windows.py index 77187c3dd..19e247195 100644 --- a/module/device/platform/emulator_windows.py +++ b/module/device/platform/emulator_windows.py @@ -1,3 +1,6 @@ +"""Windows 模拟器管理。通过 Windows 注册表和文件系统扫描 +检测夜神、蓝叠、雷电、MuMu、MEmu 等模拟器安装路径和实例。""" + import codecs import os import re diff --git a/module/device/platform/platform_base.py b/module/device/platform/platform_base.py index ab219830f..a8194a45c 100644 --- a/module/device/platform/platform_base.py +++ b/module/device/platform/platform_base.py @@ -1,3 +1,6 @@ +"""平台控制基类。定义模拟器启动、停止、重启的抽象接口, +管理 EmulatorInfo 配置和实例生命周期。""" + import os import sys import typing as t @@ -76,13 +79,13 @@ def emulator_start(self): - 需要支持重试。 - 禁止使用无聊的 sleep 来等待启动。 """ - logger.info(f'Current platform {sys.platform} does not support emulator_start, skip') + logger.info(f'[设备-平台] 当前平台 {sys.platform} 不支持启动模拟器,跳过') def emulator_stop(self): """ 停止模拟器。 """ - logger.info(f'Current platform {sys.platform} does not support emulator_stop, skip') + logger.info(f'[设备-平台] 当前平台 {sys.platform} 不支持停止模拟器,跳过') def run_remote_ssh_command(self, command=None): """ @@ -92,7 +95,7 @@ def run_remote_ssh_command(self, command=None): command: 要执行的远程命令 """ if not getattr(self.config, 'EmulatorInfo_EnableRemoteSSH', False): - logger.info('Remote SSH is not enabled (EnableRemoteSSH=False), skip') + logger.info('[设备-SSH] 远程SSH未启用 (EnableRemoteSSH=False),跳过') return host = self.config.EmulatorInfo_RemoteSSHHost @@ -101,14 +104,14 @@ def run_remote_ssh_command(self, command=None): key = getattr(self.config, 'EmulatorInfo_RemoteSSHPublicKey', '') if not command: - logger.warning('No SSH command provided, skip') + logger.warning('[设备-SSH] 未提供SSH命令,跳过') return if not host: - logger.warning(f'RemoteSSHHost is empty, skip remote SSH command: {command}') + logger.warning(f'[设备-SSH] RemoteSSHHost为空,跳过远程SSH命令: {command}') return - logger.hr('Remote SSH Command', level=1) + logger.hr('远程SSH命令', level=1) target = f'{user}@{host}' if user else host clear_ssh_host_key(host, port) # -n: 将 stdin 重定向到 /dev/null @@ -139,12 +142,12 @@ def run_remote_ssh_command(self, command=None): os.chmod(key_file, 0o600) cmd += ['-i', key_file] - logger.info(f'Using provided private key for authentication') + logger.info(f'[设备-SSH] 使用提供的私钥进行认证') except Exception as e: - logger.error(f'Failed to create or secure temporary key file: {e}') + logger.error(f'[设备-SSH] 创建或保护临时密钥文件失败: {e}') cmd += [target, command] - logger.info(f'Executing remote command: {" ".join(cmd)}') + logger.info(f'[设备-SSH] 执行远程命令: {" ".join(cmd)}') try: process = subprocess.Popen( @@ -167,7 +170,7 @@ def collect_stderr(): def collect_stdout(): for line in process.stdout: - logger.info(f'Remote: {line.strip()}') + logger.info(f'[设备-SSH] 远程输出: {line.strip()}') stderr_thread = threading.Thread(target=collect_stderr) stdout_thread = threading.Thread(target=collect_stdout) @@ -179,25 +182,25 @@ def collect_stdout(): process.wait(timeout=30) except subprocess.TimeoutExpired: process.kill() - logger.error('Remote SSH command timed out after 30 seconds') + logger.error('[设备-SSH] 远程SSH命令30秒超时') finally: stderr_thread.join(timeout=5) stdout_thread.join(timeout=5) if process.returncode == 0: - logger.info('Remote command executed successfully') + logger.info('[设备-SSH] 远程命令执行成功') else: - logger.error(f'Remote command failed with return code {process.returncode}') + logger.error(f'[设备-SSH] 远程命令失败,返回码 {process.returncode}') for line in stderr_content: - logger.error(f'Remote Error: {line}') + logger.error(f'[设备-SSH] 远程错误: {line}') except Exception as e: - logger.error(f'Failed to execute remote SSH command: {e}') + logger.error(f'[设备-SSH] 执行远程SSH命令失败: {e}') finally: if key_file and os.path.exists(key_file): try: os.remove(key_file) except Exception as e: - logger.error(f'Failed to remove temporary key file: {e}') + logger.error(f'[设备-SSH] 移除临时密钥文件失败: {e}') @cached_property def emulator_info(self) -> EmulatorInfo: @@ -291,7 +294,7 @@ def find_emulator_instance( Returns: EmulatorInstanceBase: 模拟器实例,未找到则返回 None """ - logger.hr('Find emulator instance', level=2) + logger.hr('查找模拟器实例', level=2) if emulator == 'SSH': instance = EmulatorInstanceBase( serial=serial, @@ -300,8 +303,8 @@ def find_emulator_instance( ) # 为 SSH 实例临时修改 type 属性 instance.__dict__['type'] = 'SSH' - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance (SSH): {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例 (SSH): {instance}') return instance instances = SelectedGrids(self.all_emulator_instances) @@ -312,7 +315,7 @@ def find_emulator_instance( # 按序列号搜索 select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instance with {search_args}, serial invalid') + logger.warning(f'[设备-平台] 未找到模拟器实例 {search_args},序列号无效') # MuMu12 serial 漂移修复(从原位置的死代码移到此处) # MuMu12 运行时 serial 为 127.0.0.1:16384,停止后 .nemu 配置中可能为 127.0.0.1:7555 @@ -322,9 +325,9 @@ def find_emulator_instance( select_by_id = instances.select(MuMuPlayer12_id=instance_id) if select_by_id.count >= 1: instance = select_by_id[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance (MuMu12 id match, ' - f'config serial {serial} → instance {instance}): {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-模拟器] 找到模拟器实例 (MuMu12 ID匹配,' + f'配置序列号 {serial} → 实例 {instance}): {instance}') # 更新实例的 serial 为配置中的值,确保后续启停命令使用正确的端口 instance.serial = serial return instance @@ -335,7 +338,7 @@ def find_emulator_instance( # 但配置中已保存了 EmulatorInfo_Emulator/name/path(来自上次成功运行), # 利用这些信息可以直接构造实例并启动模拟器。 if emulator and path and name and serial: - logger.info(f'Attempting fallback instance construction from config: ' + logger.info(f'[设备-模拟器] 尝试从配置构造回退实例: ' f'emulator={emulator}, name={name}, path={path}, serial={serial}') if os.path.exists(path): fallback = EmulatorInstanceBase( @@ -350,20 +353,20 @@ def find_emulator_instance( if fallback_type == emulator or not fallback_type: # 类型匹配或基类无法识别类型时,信任配置中的类型 fallback.__dict__['type'] = emulator - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance (fallback from config): {fallback}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例 (配置回退): {fallback}') return fallback else: - logger.warning(f'Fallback instance type mismatch: ' - f'expected {emulator}, got {fallback_type}') + logger.warning(f'[设备-模拟器] 回退实例类型不匹配: ' + f'预期 {emulator}, 实际 {fallback_type}') else: - logger.warning(f'Fallback path does not exist: {path}') + logger.warning(f'[设备-平台] 回退路径不存在: {path}') return None if select.count == 1: instance = select[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance: {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例: {instance}') return instance # 在多个同序列号实例中,优先按模拟器类型搜索(用户最容易配置的选项,更可靠) @@ -371,12 +374,12 @@ def find_emulator_instance( search_args['type'] = emulator select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}, type invalid') + logger.warning(f'[设备-平台] 未找到模拟器实例 {search_args},类型无效') search_args.pop('type') elif select.count == 1: instance = select[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance: {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例: {instance}') return instance # 多个同序列号实例,按名称搜索 @@ -384,12 +387,12 @@ def find_emulator_instance( search_args['name'] = name select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}, name invalid') + logger.warning(f'[设备-平台] 未找到模拟器实例 {search_args},名称无效') search_args.pop('name') elif select.count == 1: instance = select[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance: {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例: {instance}') return instance # 多个同序列号和名称的实例,按路径搜索 @@ -397,33 +400,33 @@ def find_emulator_instance( search_args['path'] = path select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}, path invalid') + logger.warning(f'[设备-平台] 未找到模拟器实例 {search_args},路径无效') search_args.pop('path') elif select.count == 1: instance = select[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance: {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例: {instance}') return instance # 仍然有多个实例,从正在运行的模拟器中查找 running = remove_duplicated_path(list(self.iter_running_emulator())) - logger.info('Running emulators') + logger.info('[设备-平台] 运行中的模拟器') for exe in running: logger.info(exe) if len(running) == 1: - logger.info('Only one running emulator') + logger.info('[设备-平台] 只有一个运行中的模拟器') # 等同于按路径搜索 search_args['path'] = running[0] select = instances.select(**search_args) if select.count == 0: - logger.warning(f'No emulator instances with {search_args}, path invalid') + logger.warning(f'[设备-平台] 未找到模拟器实例 {search_args},路径无效') search_args.pop('path') elif select.count == 1: instance = select[0] - logger.hr('Emulator instance', level=2) - logger.info(f'Found emulator instance: {instance}') + logger.hr('模拟器实例', level=2) + logger.info(f'[设备-平台] 找到模拟器实例: {instance}') return instance # 仍然有多个实例 - logger.warning(f'Found multiple emulator instances with {search_args}') + logger.warning(f'[设备-平台] 找到多个模拟器实例 {search_args}') return None diff --git a/module/device/platform/platform_mac.py b/module/device/platform/platform_mac.py index c01724ae5..fefb9bef4 100644 --- a/module/device/platform/platform_mac.py +++ b/module/device/platform/platform_mac.py @@ -1,3 +1,6 @@ +"""macOS 平台模拟器控制。继承 PlatformBase 和 EmulatorManagerMac, +实现 macOS 上模拟器的启动、停止和进程管理。""" + from __future__ import annotations import os import re @@ -36,7 +39,7 @@ def execute(cls, command, wait=True): subprocess.CompletedProcess 或 subprocess.Popen: 命令执行结果 """ # 在 Mac 上使用 shell=True 执行复杂命令 - logger.info(f'Execute: {command}') + logger.info(f'[设备-模拟器Mac] 执行: {command}') if wait: result = subprocess.run( command, @@ -64,7 +67,7 @@ def kill_process_by_regex(cls, regex: str) -> int: try: name = proc.name() if re.search(regex, name, re.IGNORECASE): - logger.info(f'Kill emulator process: {name}') + logger.info(f'[设备-模拟器Mac] 终止模拟器进程: {name}') proc.kill() count += 1 except (psutil.AccessDenied, psutil.NoSuchProcess): @@ -97,10 +100,10 @@ def renice_process_by_regex(cls, regex: str, priority: int = -20) -> int: text=True ) if result.returncode == 0: - logger.info(f'Reniced process {name} (PID: {pid}) to priority {priority}') + logger.info(f'[设备-模拟器Mac] 已调整进程优先级 {name} (PID: {pid}) 到优先级 {priority}') count += 1 else: - logger.warning(f'Failed to renice {name}: {result.stderr.strip()}') + logger.warning(f'[设备-模拟器Mac] 调整优先级失败 {name}: {result.stderr.strip()}') except (psutil.AccessDenied, psutil.NoSuchProcess): continue return count @@ -133,16 +136,16 @@ def boost_running_emulator_priority(self): # 尝试提升 MuMu 进程优先级 count = self.renice_process_by_regex(r'MuMuEmulator|MuMuPlayer', -20) if count > 0: - logger.info(f'Boosted priority for {count} MuMu process(es)') + logger.info(f'[设备-模拟器Mac] 已提升优先级 {count} 个 MuMu 进程') return # 尝试提升 BlueStacks 进程优先级 count = self.renice_process_by_regex(r'BlueStacks', -20) if count > 0: - logger.info(f'Boosted priority for {count} BlueStacks process(es)') + logger.info(f'[设备-模拟器Mac] 已提升优先级 {count} 个 BlueStacks 进程') return - logger.info('No running emulator processes found to boost') + logger.info('[设备-模拟器Mac] 未找到运行中的模拟器进程可提升') def _emulator_start(self, instance: EmulatorInstanceMac): """ @@ -178,7 +181,7 @@ def _emulator_start(self, instance: EmulatorInstanceMac): instance_index = getattr(instance, 'index', 0) self.execute(f'"{mumu_bin_path}" open {instance_index}', wait=False) else: - logger.warning(f'mumutool not found at {mumu_bin_path}, using fallback') + logger.warning(f'[设备-模拟器Mac] mumutool未找到于 {mumu_bin_path},使用回退方式') # 回退: 尝试 MuMuEmulator.app 结构 mumu_emulator_app = os.path.join(app_path, 'Contents/MacOS/MuMuEmulator.app') if os.path.exists(mumu_emulator_app): @@ -244,7 +247,7 @@ def _emulator_function_wrapper(self, func): except Exception as e: logger.exception(e) - logger.error(f'Emulator function {func.__name__}() failed') + logger.error(f'[设备-模拟器Mac] 模拟器函数 {func.__name__}() 执行失败') return False def emulator_start_watch(self): @@ -254,20 +257,20 @@ def emulator_start_watch(self): Returns: bool: True 表示启动完成,False 表示超时 """ - logger.hr('Emulator start', level=2) + logger.hr('[设备-模拟器Mac] 模拟器启动', level=2) serial = self.emulator_instance.serial @run_once def show_online(m): - logger.info(f'Emulator online: {m}') + logger.info(f'[设备-模拟器Mac] 模拟器在线: {m}') @run_once def show_ping(m): - logger.info(f'Command ping: {m}') + logger.info(f'[设备-模拟器Mac] 命令ping: {m}') @run_once def show_package(m): - logger.info(f'Found azurlane packages: {m}') + logger.info(f'[设备-模拟器Mac] 找到碧蓝航线应用包: {m}') interval = Timer(0.5).start() timeout = Timer(180).start() @@ -276,7 +279,7 @@ def show_package(m): interval.wait() interval.reset() if timeout.reached(): - logger.warning(f'Emulator start timeout') + logger.warning('[设备-模拟器Mac] 模拟器启动超时') return False try: @@ -321,12 +324,12 @@ def show_package(m): logger.exception(e) continue - logger.info('Emulator start completed') + logger.info('[设备-模拟器Mac] 模拟器启动完成') return True def emulator_start(self): """启动模拟器,最多重试 3 次。""" - logger.hr('Emulator start', level=1) + logger.hr('[设备-模拟器Mac] 模拟器启动', level=1) self.run_remote_ssh_command() for _ in range(3): # 先停止 @@ -339,7 +342,7 @@ def emulator_start(self): self.boost_emulator_priority(self.emulator_instance) if self.emulator_start_watch(): return True - logger.warning('Emulator start watch failed, retrying') + logger.warning('[设备-模拟器Mac] 模拟器启动监视失败,重试中') if self._emulator_function_wrapper(self._emulator_stop): continue else: @@ -351,12 +354,12 @@ def emulator_start(self): else: return False - logger.error('Failed to start emulator 3 times, stopped') + logger.error('[设备-模拟器Mac] 尝试3次启动模拟器失败,已停止') return False def emulator_stop(self): """停止模拟器,最多重试 3 次。""" - logger.hr('Emulator stop', level=1) + logger.hr('[设备-模拟器Mac] 模拟器停止', level=1) for _ in range(3): # 停止 if self._emulator_function_wrapper(self._emulator_stop): @@ -369,7 +372,7 @@ def emulator_stop(self): else: return False - logger.error('Failed to stop emulator 3 times, stopped') + logger.error('[设备-模拟器Mac] 尝试3次停止模拟器失败,已停止') return False diff --git a/module/device/platform/platform_windows.py b/module/device/platform/platform_windows.py index 549f98a3e..67a45f834 100644 --- a/module/device/platform/platform_windows.py +++ b/module/device/platform/platform_windows.py @@ -1,3 +1,6 @@ +"""Windows 平台模拟器控制。继承 PlatformBase 和 EmulatorManager, +实现 Windows 上模拟器的启动、窗口聚焦和进程管理。""" + from __future__ import annotations import ctypes import re @@ -74,7 +77,7 @@ def child_callback(child_hwnd, __): ) if child_found[0]: found = True - logger.warning(f'MuMu error dialog detected: "{text}"') + logger.warning(f'[设备-Windows] 检测到MuMu错误对话框: "{text}"') return True try: @@ -83,7 +86,7 @@ def child_callback(child_hwnd, __): 0 ) except Exception as e: - logger.warning(f'Failed to check MuMu error dialog: {e}') + logger.warning(f'[设备-Windows] 检查MuMu错误对话框失败: {e}') return found @@ -151,7 +154,7 @@ def execute(cls, command, wait=False, timeout=30): subprocess.CompletedProcess: 同步执行时返回完成结果 """ command = command.replace(r"\\", "/").replace("\\", "/").replace('"', '"') - logger.info(f'Execute: {command}') + logger.info(f'[设备-Windows] 执行: {command}') if wait: # 同步执行,等待命令完成 @@ -164,10 +167,10 @@ def execute(cls, command, wait=False, timeout=30): close_fds=True, creationflags=subprocess.CREATE_NO_WINDOW ) - logger.info(f'Command completed with return code: {result.returncode}') + logger.info(f'[设备-Windows] 命令完成,返回码: {result.returncode}') return result except subprocess.TimeoutExpired: - logger.warning(f'Command timeout after {timeout} seconds') + logger.warning(f'[设备-Windows] 命令超时 {timeout} 秒') return None else: # 异步执行,不等待完成(原有行为) @@ -191,7 +194,7 @@ def kill_process_by_regex(cls, regex: str) -> int: for proc in psutil.process_iter(): cmdline = DataProcessInfo(proc=proc, pid=proc.pid).cmdline if re.search(regex, cmdline): - logger.info(f'Kill emulator: {cmdline}') + logger.info(f'[设备-Windows] 终止模拟器: {cmdline}') proc.kill() count += 1 @@ -218,7 +221,7 @@ def _emulator_start(self, instance: EmulatorInstance): # the second launch request is handed over to a MuMuNxMain.exe that is still initializing # and gets silently dropped, while MuMuManager queues requests in backend service. if instance.MuMuPlayer12_id is None: - logger.warning(f'Cannot get MuMu instance index from name {instance.name}') + logger.warning(f'[设备-Windows] 无法从名称 {instance.name} 获取MuMu实例索引') self.execute(f'"{Emulator.single_to_console(exe)}" api -v {instance.MuMuPlayer12_id} launch_player') elif instance == Emulator.LDPlayerFamily: # ldconsole.exe launch --index 0 @@ -236,7 +239,7 @@ def _emulator_start(self, instance: EmulatorInstance): # MEmu.exe MEmu_0 self.execute(f'"{exe}" {instance.name}') elif instance.type == 'SSH': - logger.info('Starting SSH emulator via remote command') + logger.info('[设备-Windows] 通过远程命令启动SSH模拟器') self.run_remote_ssh_command(getattr(self.config, 'EmulatorInfo_RemoteStartCommand', '')) else: raise EmulatorUnknown(f'Cannot start an unknown emulator instance: {instance}') @@ -281,8 +284,8 @@ def _emulator_stop(self, instance: EmulatorInstance): # MuMuManager.exe api -v 1 shutdown_player # 使用同步执行等待关闭完成,避免异步执行导致的实例查找失败 if instance.MuMuPlayer12_id is None: - logger.warning(f'Cannot get MuMu instance index from name {instance.name}') - logger.info('MuMuPlayer12 shutdown_player: using synchronous execution') + logger.warning(f'[设备-Windows] 无法从名称 {instance.name} 获取MuMu实例索引') + logger.info('[设备-Windows] MuMuPlayer12 关闭: 使用同步执行') self.execute( f'"{Emulator.single_to_console(exe)}" api -v {instance.MuMuPlayer12_id} shutdown_player', wait=True, @@ -310,7 +313,7 @@ def _emulator_stop(self, instance: EmulatorInstance): # F:\Program Files\Microvirt\MEmu\memuc.exe stop -n MEmu_0 self.execute(f'"{Emulator.single_to_console(exe)}" stop -n {instance.name}') elif instance.type == 'SSH': - logger.info('Stopping SSH emulator via remote command') + logger.info('[设备-Windows] 通过远程命令停止SSH模拟器') self.run_remote_ssh_command(getattr(self.config, 'EmulatorInfo_RemoteStopCommand', '')) else: raise EmulatorUnknown(f'Cannot stop an unknown emulator instance: {instance}') @@ -332,13 +335,13 @@ def _emulator_function_wrapper(self, func: callable): msg = str(e) # OSError: [WinError 740] 请求的操作需要提升。 if 'WinError 740' in msg: - logger.error('To start/stop MumuAppPlayer, AzurPilot needs to be run as administrator') + logger.error('[设备-Windows] 启动/停止MuMu需要以管理员身份运行') except EmulatorUnknown as e: logger.error(e) except Exception as e: logger.exception(e) - logger.error(f'Emulator function {func.__name__}() failed') + logger.error(f'[设备-Windows] 模拟器函数 {func.__name__}() 失败') return False def emulator_start_watch(self): @@ -348,10 +351,10 @@ def emulator_start_watch(self): Returns: bool: True 表示启动完成,False 表示超时 """ - logger.hr('Emulator start', level=2) + logger.hr('模拟器启动', level=2) current_window = get_focused_window() serial = self.emulator_instance.serial - logger.info(f'Current window: {current_window}') + logger.info(f'[设备-Windows] 当前窗口: {current_window}') def adb_connect(): m = self.adb_client.connect(self.serial) @@ -368,15 +371,15 @@ def adb_connect(): @run_once def show_online(m): - logger.info(f'Emulator online: {m}') + logger.info(f'[设备-Windows] 模拟器在线: {m}') @run_once def show_ping(m): - logger.info(f'Command ping: {m}') + logger.info(f'[设备-Windows] 命令ping: {m}') @run_once def show_package(m): - logger.info(f'Found azurlane packages: {m}') + logger.info(f'[设备-Windows] 找到碧蓝航线应用包: {m}') interval = Timer(0.5).start() timeout = Timer(180).start() @@ -385,7 +388,7 @@ def show_package(m): interval.wait() interval.reset() if timeout.reached(): - logger.warning(f'Emulator start timeout') + logger.warning(f'[设备-Windows] 模拟器启动超时') return False try: @@ -393,7 +396,7 @@ def show_package(m): if current_window != 0 and new_window == 0: new_window = get_focused_window() if current_window != new_window: - logger.info(f'New window showing up: {new_window}, focus back') + logger.info(f'[设备-Windows] 新窗口出现: {new_window},焦点返回') set_focus_window(current_window) else: new_window = 0 @@ -445,19 +448,19 @@ def show_package(m): # MuMu 权限冲突等错误对话框检测 # 检测到错误对话框时立即终止等待,返回 False 触发重试 if check_mumu_error_dialog(): - logger.warning('MuMu emulator error dialog detected, aborting start watch') + logger.warning('[设备-Windows] 检测到MuMu错误对话框,中止启动监视') return False if new_window != 0 and new_window != current_window: - logger.info(f'Minimize new window: {new_window}') + logger.info(f'[设备-Windows] 最小化新窗口: {new_window}') minimize_window(new_window) if current_window: - logger.info(f'De-flash current window: {current_window}') + logger.info(f'[设备-Windows] 取消闪烁当前窗口: {current_window}') flash_window(current_window, flash=False) if new_window: - logger.info(f'Flash new window: {new_window}') + logger.info(f'[设备-Windows] 闪烁新窗口: {new_window}') flash_window(new_window, flash=True) - logger.info('Emulator start completed') + logger.info('[设备-Windows] 模拟器启动完成') return True def emulator_start(self): @@ -466,7 +469,7 @@ def emulator_start(self): 针对 MuMu12 等模拟器添加实例查找失败后的等待重试机制, 以及权限冲突时的强制进程清理。 """ - logger.hr('Emulator start', level=1) + logger.hr('模拟器启动', level=1) # 检查是否为 MuMuPlayer12,添加实例查找失败的处理逻辑 emulator_type = getattr(self.config, 'EmulatorInfo_Emulator', '') @@ -493,15 +496,15 @@ def emulator_start(self): if name.lower() in ('mumuplayer.exe', 'mumumanager.exe', 'nemuplayer.exe', 'nemuheadless.exe'): has_mumu_process = True - logger.warning(f'MuMu residual process detected: {name} (PID={proc.pid})') + logger.warning(f'[设备-Windows] 检测到MuMu残留进程: {name} (PID={proc.pid})') proc.kill() except (psutil.NoSuchProcess, psutil.AccessDenied): pass if has_mumu_process: - logger.info('MuMuPlayer12: killed residual processes, waiting 5 seconds') + logger.info('[设备-Windows] MuMuPlayer12: 已终止残留进程,等待5秒') time.sleep(5) else: - logger.info('MuMuPlayer12: waiting 2 seconds for process state to stabilize') + logger.info('[设备-Windows] MuMuPlayer12: 等待2秒让进程状态稳定') time.sleep(2) # 再启动 @@ -509,7 +512,7 @@ def emulator_start(self): # 成功 if self.emulator_start_watch(): return True - logger.warning('Emulator start watch failed, retrying') + logger.warning('[设备-Windows] 模拟器启动监视失败,重试中') if self._emulator_function_wrapper(self._emulator_stop): continue else: @@ -521,12 +524,12 @@ def emulator_start(self): else: return False - logger.error('Failed to start emulator 3 times, stopped') + logger.error('[设备-Windows] 尝试3次启动模拟器失败,已停止') return False def emulator_stop(self): """停止模拟器,最多重试 3 次。""" - logger.hr('Emulator stop', level=1) + logger.hr('模拟器停止', level=1) for _ in range(3): # 停止 if self._emulator_function_wrapper(self._emulator_stop): @@ -539,7 +542,7 @@ def emulator_stop(self): else: return False - logger.error('Failed to stop emulator 3 times, stopped') + logger.error('[设备-Windows] 尝试3次停止模拟器失败,已停止') return False diff --git a/module/device/platform/utils.py b/module/device/platform/utils.py index 4ebf94af1..67d91145e 100644 --- a/module/device/platform/utils.py +++ b/module/device/platform/utils.py @@ -1,3 +1,6 @@ +"""平台层通用工具。提供 cached_property 装饰器(带泛型支持) +和 iter_folder 目录遍历辅助函数。""" + import os from typing import Callable, Generic, TypeVar diff --git a/module/device/screenshot.py b/module/device/screenshot.py index 7fbae6b72..9e1d8de4e 100644 --- a/module/device/screenshot.py +++ b/module/device/screenshot.py @@ -1,3 +1,9 @@ +"""设备截图模块。 + +管理所有截图捕获后端(ADB、ADB_nc、uiautomator2、aScreenCap、DroidCast、 +scrcpy、nemu_ipc、ldopengl),提供截图、分辨率校验、黑屏检测、截图保存等功能。 +包含后台编码线程,用于将图像序列化并通过 Base64 供 WebUI 实时渲染预览。 +""" import os import time from collections import deque @@ -26,7 +32,19 @@ from module.logger import logger class Screenshot(Adb, WSA, DroidCast, AScreenCap, Scrcpy, NemuIpc, LDOpenGL): - + """设备截图管理器。 + + 通过多重继承组合所有截图后端,根据用户配置的 Emulator_ScreenshotMethod + 自动分发到对应后端。提供截图获取、分辨率归一化、去抖动、黑屏检测、 + 截图保存和间隔控制等统一接口。 + + Attributes: + image (np.ndarray): 最近一次截取的屏幕图像,格式为 BGR numpy 数组。 + _screen_size_checked (bool): 屏幕分辨率是否已通过检查。 + _screen_black_checked (bool): 黑屏检测是否已通过。 + _screenshot_interval (Timer): 截图间隔计时器。 + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) _screen_size_checked = False @@ -38,6 +56,12 @@ def __init__(self, *args, **kwargs): @cached_property def screenshot_methods(self): + """返回截图方法名到截图实现的映射字典。 + + Returns: + dict[str, Callable]: 键为截图方法名(如 'ADB'、'DroidCast'), + 值为对应的截图方法。 + """ return { 'ADB': self.screenshot_adb, 'ADB_nc': self.screenshot_adb_nc, @@ -53,6 +77,11 @@ def screenshot_methods(self): @cached_property def screenshot_method_override(self) -> str: + """覆盖截图方法,子类可重写此属性以强制使用特定截图方式。 + + Returns: + str: 覆盖的截图方法名,空字符串表示使用配置中的方法。 + """ return '' def screenshot(self): @@ -106,6 +135,11 @@ def resize_screenshot_to_720p(image): @property def has_cached_image(self): + """判断是否已有缓存的截图。 + + Returns: + bool: 存在非空的缓存图像返回 True。 + """ return hasattr(self, 'image') and self.image is not None def _handle_orientated_image(self, image): @@ -137,10 +171,17 @@ def _handle_orientated_image(self, image): @cached_property def screenshot_deque(self): + """返回用于保存历史截图的双端队列,用于错误诊断。 + + 队列长度由配置 Error_ScreenshotLength 控制,限制在 1~400 范围内。 + + Returns: + deque: 存储 {'time': datetime, 'image': np.ndarray} 字典的队列。 + """ try: length = int(self.config.Error_ScreenshotLength) except ValueError: - logger.error(f'Error_ScreenshotLength={self.config.Error_ScreenshotLength} is not an integer') + logger.error(f'[设备-截图] Error_ScreenshotLength={self.config.Error_ScreenshotLength} 不是整数') raise RequestHumanTakeover # 限制在 1~400 范围内 length = max(1, min(length, 400)) @@ -179,6 +220,11 @@ def save_screenshot(self, genre='items', interval=None, to_base_folder=False): return False def screenshot_last_save_time_reset(self, genre): + """重置指定类型的截图保存时间戳,用于允许立即保存下一张截图。 + + Args: + genre (str): 截图类型名称。 + """ self._last_save_time[genre] = 0 def screenshot_interval_set(self, interval=None): @@ -193,7 +239,7 @@ def screenshot_interval_set(self, interval=None): origin = self.config.Optimization_ScreenshotInterval interval = limit_in(origin, 0.001, 0.3) if interval != origin: - logger.warning(f'Optimization.ScreenshotInterval {origin} is revised to {interval}') + logger.warning(f'[设备-截图] Optimization.ScreenshotInterval {origin} 修正为 {interval}') self.config.Optimization_ScreenshotInterval = interval # 允许 nemu_ipc 使用更低的默认值 if self.config.Emulator_ScreenshotMethod in ['nemu_ipc', 'ldopengl']: @@ -202,28 +248,38 @@ def screenshot_interval_set(self, interval=None): origin = self.config.Optimization_CombatScreenshotInterval interval = limit_in(origin, 0.001, 1.0) if interval != origin: - logger.warning(f'Optimization.CombatScreenshotInterval {origin} is revised to {interval}') + logger.warning(f'[设备-截图] Optimization.CombatScreenshotInterval {origin} 修正为 {interval}') self.config.Optimization_CombatScreenshotInterval = interval elif isinstance(interval, (int, float)): # 代码中手动设置无限制 pass else: - logger.warning(f'Unknown screenshot interval: {interval}') - raise ScriptError(f'Unknown screenshot interval: {interval}') + logger.warning(f'[设备-截图] 未知的截图间隔: {interval}') + raise ScriptError(f'[设备-截图] 未知的截图间隔: {interval}') # scrcpy 的截图间隔无意义,视频流会持续接收,无论是否使用。 if self.config.Emulator_ScreenshotMethod == 'scrcpy': interval = 0.1 if interval != self._screenshot_interval.limit: - logger.info(f'Screenshot interval set to {interval}s') + logger.info(f'[设备-截图] 截图间隔设置为 {interval}s') self._screenshot_interval.limit = interval def image_show(self, image=None): + """使用系统默认图片查看器显示图像。 + + Args: + image (np.ndarray, optional): 要显示的图像,默认为最近一次截图。 + """ if image is None: image = self.image Image.fromarray(image).show() def image_save(self, file=None): + """将最近一次截图保存到文件。 + + Args: + file (str, optional): 保存路径,默认使用毫秒时间戳命名。 + """ if file is None: file = f'{int(time.time() * 1000)}.png' save_image(self.image, file) @@ -240,18 +296,18 @@ def check_screen_size(self): for _ in range(2): # 检查屏幕分辨率 width, height = image_size(self.image) - logger.attr('Screen_size', f'{width}x{height}') + logger.attr('屏幕分辨率', f'{width}x{height}') if width == 1280 and height == 720: self._screen_size_checked = True return True elif not orientated and (width == 720 and height == 1280): - logger.info('Received orientated screenshot, handling') + logger.info('[设备-截图] 收到方向截图,处理中') self.get_orientation() self.image = self._handle_orientated_image(self.image) orientated = True width, height = image_size(self.image) if width == 720 and height == 1280: - logger.info('Unable to handle orientated screenshot, continue for now') + logger.info('[设备-截图] 无法处理方向截图,暂时继续') return True else: continue @@ -259,7 +315,7 @@ def check_screen_size(self): self.display_resize_wsa(0) return False elif hasattr(self, 'app_is_running') and not self.app_is_running(): - logger.warning('Received orientated screenshot, game not running') + logger.warning('[设备-截图] 收到方向截图,游戏未运行') return True else: logger.error_context( @@ -272,6 +328,14 @@ def check_screen_size(self): raise RequestHumanTakeover def check_screen_black(self): + """检查截图是否为纯黑色(模拟器异常或设备锁屏)。 + + 首次调用时执行检测,通过后后续调用直接返回 True。 + 检测到黑屏时会尝试卸载 minicap 或重启相关服务。 + + Returns: + bool: 屏幕正常返回 True,纯黑截图返回 False 以触发重试。 + """ if self._screen_black_checked: return True # 检查屏幕颜色,某些模拟器可能会获取纯黑截图。 @@ -282,26 +346,26 @@ def check_screen_black(self): display = self.get_display_id() if display == 0: return True - logger.info(f'Game running on display {display}') - logger.warning('Game not running on display 0, will be restarted') + logger.info(f'[设备-截图] 游戏运行在显示器 {display}') + logger.warning('[设备-截图] 游戏未运行在显示器0,将重启') self.app_stop_uiautomator2() return False elif self.config.Emulator_ScreenshotMethod == 'uiautomator2': - logger.warning(f'Received pure black screenshots from emulator, color: {color}') - logger.warning('Uninstall minicap and retry') - logger.warning('[Device] 截图为纯黑色。通常是设备处于锁屏状态,或者当前模拟器不支持当前截图方式。') + logger.warning(f'[设备-截图] 收到模拟器纯黑截图, color: {color}') + logger.warning('[设备-截图] 卸载minicap并重试') + logger.warning('[设备-截图] 截图为纯黑色。通常是设备处于锁屏状态,或者当前模拟器不支持当前截图方式。') self.uninstall_minicap() self._screen_black_checked = False return False else: - logger.warning(f'Received pure black screenshots from emulator, color: {color}') - logger.warning(f'Screenshot method `{self.config.Emulator_ScreenshotMethod}` ' - f'may not work on emulator `{self.serial}`, or the emulator is not fully started') + logger.warning(f'[设备-截图] 收到模拟器纯黑截图, color: {color}') + logger.warning(f'[设备-截图] 截图方式 `{self.config.Emulator_ScreenshotMethod}` ' + f'在模拟器 `{self.serial}` 上可能无法正常工作,或模拟器未完全启动') if self.is_mumu_family: if self.config.Emulator_ScreenshotMethod == 'DroidCast': self.droidcast_stop() else: - logger.warning('If you are using MuMu X, please upgrade to version >= 12.1.5.0') + logger.warning('[设备-截图] 如果使用的是 MuMu X,请升级到版本 >= 12.1.5.0') self._screen_black_checked = False return False else: diff --git a/module/dorm/assets.py b/module/dorm/assets.py index f6f60d49c..6a21e61eb 100644 --- a/module/dorm/assets.py +++ b/module/dorm/assets.py @@ -1,3 +1,5 @@ +"""后宅模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/dorm/buy_furniture.py b/module/dorm/buy_furniture.py index 77d750cda..66edf645c 100644 --- a/module/dorm/buy_furniture.py +++ b/module/dorm/buy_furniture.py @@ -1,3 +1,12 @@ +""" +后宅家具购买模块。 + +自动检测并购买宿舍家具商店中的限时家具。功能包括: +- 进入宿舍家具商店并浏览家具详情 +- 通过 OCR 识别家具代币余额和价格,判断是否足够购买 +- 根据配置选择购买套装或全部购买 +- 支持按固定间隔(默认 6 天)自动检查并购买 +""" from datetime import timedelta from module.combat.assets import GET_SHIP @@ -21,6 +30,17 @@ class BuyFurniture(UI): + """ + 家具购买处理器,负责自动检测和购买限时家具。 + + 继承自 UI,通过 OCR 识别家具代币余额和价格, + 在检测到限时家具时自动购买。支持套装购买和全部购买两种模式。 + + Attributes: + CHECK_INTERVAL (int): 检查间隔天数,默认 6 天。 + FURNITURE_BUY_BUTTON (dict): 购买按钮映射,包含 "all" 和 "set" 两种选项。 + """ + def enter_first_furniture_details_page(self, skip_first_screenshot=False): """ Pages: @@ -167,10 +187,10 @@ def buy_furniture_once(self, buy_option: str): # Successful or failed buy will have popup and back to furniture details page, # produce the result from furniture coin compare to furniture price. if coin >= price > 0: - logger.info(f"Enough furniture coin, buy {buy_option}") + logger.info(f"[宿舍-家具] 家具币充足,购买 {buy_option}") buy_successful = True else: - logger.info(f"Not enough furniture coin, purchase is over") + logger.info(f"[宿舍-家具] 家具币不足,购买结束") buy_successful = False self.buy_furniture_confirm(skip_first_screenshot=True) self.furniture_details_page_quit(skip_first_screenshot=True) @@ -186,15 +206,15 @@ def _buy_furniture_run(self): """ self.enter_first_furniture_details_page() if self.match_template_color(DORM_FURNITURE_COUNTDOWN, offset=(20, 20)): - logger.info("There is a time-limited furniture available for buy") + logger.info("[宿舍-家具] 发现限时家具可购买") if self.buy_furniture_once(self.config.BuyFurniture_BuyOption): - logger.info("Find next time-limited furniture") + logger.info("[宿舍-家具] 查找下一个限时家具") return True # continue else: return False # break else: - logger.info("No time-limited furniture found") + logger.info("[宿舍-家具] 未找到限时家具") return False # break def buy_furniture_run(self): @@ -203,14 +223,14 @@ def buy_furniture_run(self): in: DORM_FURNITURE_DETAILS_ENTER (furniture shop page) out: page_dorm """ - logger.info("Buy furniture run") + logger.info("[宿舍-家具] 开始购买家具") while 1: if self._buy_furniture_run(): continue else: break # Quit to page_dorm - logger.info("Fallback to dorm_page") + logger.info("[宿舍-家具] 回退到宿舍页面") self.furniture_details_page_quit(skip_first_screenshot=True) self.furniture_shop_quit(skip_first_screenshot=True) self.config.BuyFurniture_LastRun = current_time().replace(microsecond=0) @@ -219,14 +239,14 @@ def run(self): """ Run Buy Furniture """ - logger.attr("BuyFurniture_LastRun", self.config.BuyFurniture_LastRun) - logger.attr("CHECK_INTERVAL", CHECK_INTERVAL) + logger.attr("上次运行时间", self.config.BuyFurniture_LastRun) + logger.attr("检查间隔", CHECK_INTERVAL) time_run = self.config.BuyFurniture_LastRun + timedelta(days=CHECK_INTERVAL) - logger.info(f"Task BuyFurniture run time is {time_run}") + logger.info(f"[宿舍-家具] 任务运行时间: {time_run}") if current_time().replace(microsecond=0) < time_run: - logger.info("Not running time, skip") + logger.info("[宿舍-家具] 未到运行时间,跳过") return self.buy_furniture_run() diff --git a/module/dorm/dorm.py b/module/dorm/dorm.py index b4d58f5e8..9113ed0ca 100644 --- a/module/dorm/dorm.py +++ b/module/dorm/dorm.py @@ -1,3 +1,15 @@ +""" +后宅(宿舍)任务模块。 + +自动执行后宅的日常维护操作,包括: +- 收取宿舍中的爱心和金币 +- 使用食物喂食舰船(支持长按喂食和多次点击) +- 购买食物并管理食物库存 +- 计算下次任务延迟时间 + +食物喂食支持多种控制后端:minitouch、MaaTouch、uiautomator2、nemu_ipc。 +通过 OCR 识别食物数量和饱食度,通过模板匹配识别爱心和金币。 +""" import time import typing as t @@ -43,7 +55,7 @@ def after_process(self, result): if res: # 10005800 -> 1000/5800 new = f'{res.group(1)}/{exp}' - logger.info(f'OcrDormFood result {result} is revised to {new}') + logger.info(f'[宿舍-OCR] 食物OCR结果 {result} 修正为 {new}') result = new break @@ -72,6 +84,20 @@ def __eq__(self, other): class RewardDorm(UI): + """ + 后宅奖励处理器,负责宿舍的日常维护操作。 + + 继承自 UI,提供以下功能: + - 收取宿舍中的爱心和金币(手动点击或快捷收取) + - 使用食物喂食舰船(支持多种喂食方式) + - 购买食物并管理食物库存 + - 获取宿舍舰船数量并计算任务延迟 + + Attributes: + _dorm_food (ButtonGrid): 食物按钮网格,6 种食物从左到右排列。 + _dorm_food_ocr (Digit): 食物数量 OCR,识别每种食物的库存。 + """ + def _dorm_receive_click(self): """ 点击宿舍中的爱心和金币进行收取。 @@ -86,13 +112,13 @@ def _dorm_receive_click(self): image = MASK_DORM.apply(self.device.image) loves = TEMPLATE_DORM_LOVE.match_multi(image, name='DORM_LOVE') coins = TEMPLATE_DORM_COIN.match_multi(image, name='DORM_COIN') - logger.info(f'Dorm loves: {len(loves)}, Dorm coins: {len(coins)}') + logger.info(f'[宿舍-收取] 爱心: {len(loves)}, 金币: {len(coins)}') # 复杂的宿舍背景可能导致误检 if len(loves) > 6: - logger.warning('Too many dorm loves, limited to 6') + logger.warning('[宿舍-收取] 爱心数量超过6个,限制为6') loves = loves[:6] if len(coins) > 6: - logger.warning('Too many dorm coins, limited to 6') + logger.warning('[宿舍-收取] 金币数量超过6个,限制为6') coins = coins[:6] count = 0 @@ -127,7 +153,7 @@ def _dorm_feed_long_tap(self, button, count): or self.appear(POPUP_CONFIRM, offset=self._popup_offset): break if timeout.reached(): - logger.warning('Wait dorm feed timeout') + logger.warning('[宿舍-喂食] 喂食等待超时') break builder.up().commit() @@ -151,7 +177,7 @@ def _dorm_feed_long_tap(self, button, count): or self.appear(POPUP_CONFIRM, offset=self._popup_offset): break if timeout.reached(): - logger.warning('Wait dorm feed timeout') + logger.warning('[宿舍-喂食] 喂食等待超时') break builder.up().commit() @@ -173,7 +199,7 @@ def _dorm_feed_long_tap(self, button, count): or self.appear(POPUP_CONFIRM, offset=self._popup_offset): break if timeout.reached(): - logger.warning('Wait dorm feed timeout') + logger.warning('[宿舍-喂食] 喂食等待超时') break self.device.u2.touch.up(x, y) @@ -193,15 +219,15 @@ def _dorm_feed_long_tap(self, button, count): or self.appear(POPUP_CONFIRM, offset=self._popup_offset): break if timeout.reached(): - logger.warning('Wait dorm feed timeout') + logger.warning('[宿舍-喂食] 喂食等待超时') break self.device.nemu_ipc.up() @Config.when(DEVICE_CONTROL_METHOD=None) def _dorm_feed_long_tap(self, button, count): - logger.warning(f'Current control method {self.config.Emulator_ControlMethod} ' - f'does not support DOWN/UP events, use multi-click instead') + logger.warning(f'[宿舍-喂食] 当前控制方式 {self.config.Emulator_ControlMethod} ' + f'不支持DOWN/UP事件,使用多次点击代替') self.device.multi_click(button, count) def dorm_view_reset(self): @@ -212,7 +238,7 @@ def dorm_view_reset(self): in: page_dorm out: page_dorm """ - logger.info('Dorm view reset') + logger.info('[宿舍-视角] 重置宿舍视角') for _ in self.loop(): # 结束 if self.appear(DORM_MANAGE_CHECK, offset=(20, 20)): @@ -242,7 +268,7 @@ def dorm_collect(self): in: page_dorm out: page_dorm """ - logger.hr('Dorm collect') + logger.hr('后宅收取') self.ensure_no_info_bar() @@ -264,7 +290,7 @@ def dorm_collect(self): # 超时结束 if timeout.reached(): - logger.warning('Dorm collect timeout, probably because Alas did not detect the info_bar') + logger.warning('[宿舍-收取] 收取超时,可能未检测到信息栏') break @cached_property @@ -277,6 +303,15 @@ def _dorm_food_ocr(self): return Digit(grids.buttons, letter=(255, 255, 255), threshold=128, name='OCR_DORM_FOOD') def _dorm_has_food(self, button): + """ + 检测指定食物按钮是否有食物(非空槽位)。 + + Args: + button (Button): 食物按钮。 + + Returns: + bool: 有食物返回 True,空槽位返回 False。 + """ return np.min(rgb2gray(self.image_crop(button, copy=False))) < 127 def _dorm_feed_click(self, button, count): @@ -290,7 +325,7 @@ def _dorm_feed_click(self, button, count): Pages: in: DORM_FEED_CHECK """ - logger.info(f'Dorm feed {button} x {count}') + logger.info(f'[宿舍-喂食] 喂食 {button} x {count}') if count <= 3: for _ in range(count): self.device.click(button) @@ -327,7 +362,7 @@ def dorm_food_get(self): _, fill, total = OCR_FILL.ocr(self.device.image) if total == 0: fill = -1 - logger.info(f'Dorm food: {[f.amount for f in food]}, to fill: {fill}') + logger.info(f'[宿舍-食物] 食物库存: {[f.amount for f in food]}, 需填充: {fill}') return food, fill def dorm_feed_once(self): @@ -346,7 +381,7 @@ def dorm_feed_once(self): for _ in self.loop(): # 结束 if timeout.reached(): - logger.warning('Get dorm food timeout, probably because food is empty') + logger.warning('[宿舍-食物] 获取食物信息超时,可能食物为空') break if self.handle_info_bar(): @@ -381,18 +416,22 @@ def dorm_feed(self): Pages: in: DORM_FEED_CHECK """ - logger.hr('Dorm feed') + logger.hr('后宅喂食') for n in range(10): if not self.dorm_feed_once(): - logger.info('Dorm feed finished') + logger.info('[宿舍-喂食] 喂食完成') return n - logger.warning('Dorm feed run count reached') + logger.warning('[宿舍-喂食] 喂食次数达到上限') return 10 def dorm_feed_enter(self): """ + 从宿舍主页进入喂食界面。 + + 处理可能遇到的管理界面、家具商店等中间状态。 + Pages: in: DORM_CHECK out: DORM_FEED_CHECK @@ -411,19 +450,21 @@ def dorm_feed_enter(self): continue if self.appear(DORM_MANAGE_CHECK, offset=(20, 20), interval=5): self.device.click(DORM_FURNITURE_SHOP_QUIT) - logger.info(f'{DORM_MANAGE_CHECK} -> {DORM_FURNITURE_SHOP_QUIT}') + logger.info(f'[宿舍-视角] {DORM_MANAGE_CHECK} -> {DORM_FURNITURE_SHOP_QUIT}') continue if self.appear(DORM_FURNITURE_SHOP_FIRST, offset=(20, 20), interval=5): self.device.click(DORM_FURNITURE_SHOP_QUIT) - logger.info(f'{DORM_FURNITURE_SHOP_FIRST} -> {DORM_FURNITURE_SHOP_QUIT}') + logger.info(f'[宿舍-视角] {DORM_FURNITURE_SHOP_FIRST} -> {DORM_FURNITURE_SHOP_QUIT}') continue if self.appear(DORM_FURNITURE_SHOP_FIRST_SELECTED, offset=(20, 20), interval=5): self.device.click(DORM_FURNITURE_SHOP_QUIT) - logger.info(f'{DORM_FURNITURE_SHOP_FIRST_SELECTED} -> {DORM_FURNITURE_SHOP_QUIT}') + logger.info(f'[宿舍-视角] {DORM_FURNITURE_SHOP_FIRST_SELECTED} -> {DORM_FURNITURE_SHOP_QUIT}') continue def dorm_feed_quit(self): """ + 从喂食界面返回宿舍主页。 + Pages: in: DORM_FEED_CHECK out: DORM_CHECK @@ -446,6 +487,8 @@ def dorm_feed_quit(self): def dorm_buy_food_enter(self): """ + 从喂食界面进入购买食物界面。 + Pages: in: DORM_FEED_CHECK out: DORM_BUY_FOOD_CHECK @@ -468,7 +511,7 @@ def dorm_buy_food(self, amount): in: DORM_BUY_FOOD_CHECK out: DORM_BUY_FOOD_CHECK """ - logger.hr('Dorm buy food') + logger.hr('后宅购买食物') index_offset = (20, 20) # 防止 +/- 按钮位置偏移,使用船坞 OCR 技巧准确解析 self.appear(FOOD_PLUS, offset=index_offset) @@ -480,6 +523,8 @@ def dorm_buy_food(self, amount): def dorm_buy_food_confirm(self): """ + 确认购买食物并返回喂食界面。 + Pages: in: DORM_BUY_FOOD_CHECK out: DORM_FEED_CHECK @@ -510,7 +555,7 @@ def dorm_food_run(self, amount): self.ui_ensure(page_dormmenu) self.handle_info_bar() self.ui_goto(page_dorm, skip_first_screenshot=True) - logger.hr('Dorm buy food', level=1) + logger.hr('后宅购买食物', level=1) self.dorm_feed_enter() self.dorm_buy_food_enter() self.dorm_buy_food(amount=amount) @@ -521,6 +566,14 @@ def dorm_run(self, feed=True, collect=True, buy_furniture=False): """ 执行宿舍操作:喂食、收取、购买家具。 + 操作顺序:先喂食(处理 DORM_INFO 弹窗避免遮挡),再收取金币和爱心, + 最后购买家具。 + + Args: + feed (bool): 是否执行喂食。 + collect (bool): 是否收取金币和爱心。 + buy_furniture (bool): 是否购买家具。 + Pages: in: 任意页面 out: page_dorm @@ -541,17 +594,17 @@ def dorm_run(self, feed=True, collect=True, buy_furniture=False): # 先喂食以处理 DORM_INFO # DORM_INFO 可能会遮挡宿舍金币和爱心 if feed: - logger.hr('Dorm feed', level=1) + logger.hr('后宅喂食', level=1) self.dorm_feed_enter() self.dorm_feed() self.dorm_feed_quit() if collect: - logger.hr('Dorm collect', level=1) + logger.hr('后宅收取', level=1) self.dorm_collect() if buy_furniture: - logger.hr('Dorm buy furniture', level=1) + logger.hr('后宅购买家具', level=1) BuyFurniture(self.config, self.device).run() def get_dorm_ship_amount(self): @@ -578,12 +631,12 @@ def get_dorm_ship_amount(self): current, _, total = OCR_SLOT.ocr(self.device.image) if timeout.reached(): - logger.warning('Get dorm slots timeout') + logger.warning('[宿舍-栏位] 获取宿舍栏位超时') break if total == 0: continue elif current not in [0, 1, 2, 3, 4, 5, 6]: - logger.warning(f'Invalid dorm slot amount: {current}') + logger.warning(f'[宿舍-栏位] 无效的宿舍栏位数量: {current}') continue else: break @@ -647,5 +700,5 @@ def run(self): # Scheduler ships = self.get_dorm_ship_amount() delay = self.cal_dorm_delay(ships) - logger.info(f'Ships in dorm: {ships}, task to delay: {delay}') + logger.info(f'[宿舍-调度] 宿舍舰船数: {ships}, 任务延迟: {delay}') self.config.task_delay(minute=delay) diff --git a/module/equipment/assets.py b/module/equipment/assets.py index e66d78fa1..6d7676754 100644 --- a/module/equipment/assets.py +++ b/module/equipment/assets.py @@ -1,3 +1,5 @@ +"""装备模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/equipment/equipment.py b/module/equipment/equipment.py index d636db33f..941ccc710 100644 --- a/module/equipment/equipment.py +++ b/module/equipment/equipment.py @@ -1,3 +1,24 @@ +"""装备管理系统。 + +管理舰船装备的查看、更换和装备码操作。 + +功能: +- 装备界面的导航和检测 +- 舰船视图的左右滑动切换 +- 装备编辑按钮的状态管理 +- 装备过滤器(正在装备中)的切换 +- 装备码(Equipment Code)的导入导出 + +装备类型: +- 主炮(Main Gun) +- 副炮(Secondary Gun) +- 鱼雷(Torpedo) +- 防空炮(Anti-Air Gun) +- 设备(Auxiliary Device) + +继承自 EquipmentCodeHandler,提供装备码处理能力。 +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.base.timer import Timer @@ -9,8 +30,10 @@ from module.ui.navbar import Navbar from module.ui.switch import Switch +# 滑动参数 SWIPE_DISTANCE = 250 SWIPE_RANDOM_RANGE = (-40, -20, 40, 20) +# 装备中过滤器开关 equipping_filter = Switch('Equiping_filter') equipping_filter.add_state('on', check_button=EQUIPPING_ON) equipping_filter.add_state('off', check_button=EQUIPPING_OFF) @@ -19,6 +42,13 @@ class Equipment(EquipmentCodeHandler): + """装备管理器。 + + 管理舰船装备的查看和更换操作。 + + Attributes: + equipment_has_take_on (bool): 是否已执行装备更换操作。 + """ equipment_has_take_on = False def equipping_set(self, enable=False): @@ -47,7 +77,7 @@ def _ship_view_swipe(self, distance, check_button=EQUIPMENT_OPEN): if self.appear(check_button, offset=(30, 30)): break if self.appear(RETIRE_EQUIP_CONFIRM, offset=(30, 30)): - logger.info('RETIRE_EQUIP_CONFIRM popup in _ship_view_swipe()') + logger.info('[装备-穿戴] 退役装备确认弹窗') return False # Popup when enhancing a NPC ship if self.handle_popup_confirm('SHIP_VIEW_SWIPE'): @@ -57,16 +87,16 @@ def _ship_view_swipe(self, distance, check_button=EQUIPMENT_OPEN): self.device.screenshot() if self.appear(RETIRE_EQUIP_CONFIRM, offset=(30, 30)): - logger.info('RETIRE_EQUIP_CONFIRM popup in _ship_view_swipe()') + logger.info('[装备-穿戴] 退役装备确认弹窗') return False if SWIPE_CHECK.match(self.device.image): if swipe_count > 1: - logger.info('Same ship on multiple swipes') + logger.info('[装备-穿戴] 多次滑动同一舰船') return False continue if self.appear(check_button, offset=(30, 30)) and not SWIPE_CHECK.match(self.device.image): - logger.info('New ship detected on swipe') + logger.info('[装备-穿戴] 滑动检测到新舰船') return True def ship_view_next(self, check_button=EQUIPMENT_OPEN): @@ -91,7 +121,7 @@ def ship_info_enter(self, click_button, check_button=EQUIPMENT_OPEN, long_click= # Long click accidentally became normal click, exit from dock if long_click: if self.appear(DOCK_CHECK, offset=(20, 20), interval=3): - logger.info(f'ship_info_enter {DOCK_CHECK} -> {BACK_ARROW}') + logger.info(f'[装备-穿戴] 舰船信息进入 {DOCK_CHECK} -> {BACK_ARROW}') self.device.click(BACK_ARROW) continue if enter_timer.reached(): @@ -160,7 +190,7 @@ def ship_side_navbar_ensure(self, upper=None, bottom=None): """ if self._ship_side_navbar.get_total(main=self) == 3: if upper == 1 or bottom == 3: - logger.warning('Transitions to "research" is not supported') + logger.warning('[装备-穿戴] 不支持跳转到 "research"') return False if self._ship_side_navbar.set(self, upper=upper, bottom=bottom): @@ -191,7 +221,7 @@ def ship_equipment_take_on(self, name=None): self.code_apply(name=name) def _equip_take_off_one(self, skip_first_screenshot=True): - logger.info('Equipment take off') + logger.info('[装备-穿戴] 装备卸下') bar_timer = Timer(5) off_timer = Timer(5) confirm_timer = Timer(5) @@ -226,7 +256,7 @@ def _equip_take_off_one(self, skip_first_screenshot=True): bar_timer.reset() continue - logger.info('Equipment take off ended') + logger.info('[装备-穿戴] 装备卸下完成') def equipment_take_off(self, enter, out, fleet): """ @@ -235,7 +265,7 @@ def equipment_take_off(self, enter, out, fleet): out (Button): Button to confirm exit success. fleet (list[int]): list of equipment record. [3, 1, 1, 1, 1, 1] """ - logger.hr('Equipment take off') + logger.hr('[装备-穿戴] 装备卸下') self.equip_enter(enter) for index in '9'.join([str(x) for x in fleet if x > 0]): @@ -250,7 +280,7 @@ def equipment_take_off(self, enter, out, fleet): self.equipment_has_take_on = False def _equip_take_on_one(self, index, skip_first_screenshot=True): - logger.info('Equipment take on preset') + logger.info('[装备-穿戴] 装备预设装上') bar_timer = Timer(5) on_timer = Timer(5) @@ -280,7 +310,7 @@ def _equip_take_on_one(self, index, skip_first_screenshot=True): bar_timer.reset() continue - logger.info('Equipment take on ended') + logger.info('[装备-穿戴] 装备装上完成') def equipment_take_on(self, enter, out, fleet): """ @@ -289,7 +319,7 @@ def equipment_take_on(self, enter, out, fleet): out (Button): Button to confirm exit success. fleet (list[int]): list of equipment record. [3, 1, 1, 1, 1, 1] """ - logger.hr('Equipment take on') + logger.hr('[装备-穿戴] 装备装上') self.equip_enter(enter) for index in '9'.join([str(x) for x in fleet if x > 0]): diff --git a/module/equipment/equipment_change.py b/module/equipment/equipment_change.py index e465c5faa..eaa2c3f0c 100644 --- a/module/equipment/equipment_change.py +++ b/module/equipment/equipment_change.py @@ -1,3 +1,7 @@ +"""装备更换核心逻辑,处理舰船详细页面中的装备栏操作。 +包括装备筛选、装备选择、滚动翻页、匹配确认等步骤, +继承 Equipment 基类提供完整的装备更换流程。""" + from module.base.button import ButtonGrid from module.base.decorator import Config from module.base.utils import * @@ -34,7 +38,7 @@ def ship_equipment_record_image(self, index_list=range(0, 5)): Record equipment through upgrade page Notice: The equipment icons in the upgrade page are the same size as the icons in the equipment status """ - logger.info('RECORD EQUIPMENT') + logger.info('[装备-更换] 记录装备') self.equip_side_navbar_ensure(bottom=1) # Ensure EQUIPMENT_GRID in the right place @@ -75,20 +79,20 @@ def ship_equipment_record_image(self, index_list=range(0, 5)): click_button=UPGRADE_QUIT, check_button=EQUIPMENT_OPEN, appear_button=UPGRADE_ENTER_CHECK, skip_first_screenshot=True) else: - logger.info(f"Equipment {index} is empty") + logger.info(f"[装备-更换] 装备栏 {index} 为空") - logger.info(f"Equipping list: {list(self.equip_list.keys())}") + logger.info(f"[装备-更换] 装备列表: {list(self.equip_list.keys())}") def equipment_take_on(self, index_list=range(0, 5), skip_first_screenshot=True): ''' Equip the equipment previously recorded ''' - logger.info('Take on equipment') + logger.info('[装备-更换] 装上装备') self.equip_side_navbar_ensure(bottom=2) for index in index_list: if index in self.equip_list: - logger.info(f'Take on {index}') + logger.info(f'[装备-更换] 装上装备 {index}') enter_button = globals()[ 'EQUIP_TAKE_ON_{index}'.format(index=index)] @@ -125,11 +129,11 @@ def _equip_equipment(self, point, offset=(100, 100)): in: EQUIPMENT STATUS out: SHIP_SIDEBAR_EQUIPMENT ''' - logger.info('Equip equipment') + logger.info('[装备-更换] 装备装备') button = Button(area=(), color=(), button=(point[0], point[1], point[0] + offset[0], point[1] + offset[1]), name='EQUIPMENT') self.ui_click(appear_button=EQUIPPING_OFF, click_button=button, check_button=EQUIP_CONFIRM) - logger.info('Equip confirm') + logger.info('[装备-更换] 装备确认') self.ui_click(click_button=EQUIP_CONFIRM, check_button=SHIP_INFO_EQUIPMENT_CHECK) def _find_equip(self, index): @@ -150,7 +154,7 @@ def _find_equip(self, index): return if not EQUIPMENT_SCROLL.appear(main=self): - logger.warning('No recorded equipment was found.') + logger.warning('[装备-更换] 未找到记录的装备') self.ui_back(check_button=globals()[f'EQUIP_TAKE_ON_{index}'], appear_button=EQUIPPING_OFF) return @@ -168,7 +172,7 @@ def _find_equip(self, index): self._equip_equipment(point) break if self.appear(EQUIPMENT_SCROLL_BOTTOM): - logger.warning('No recorded equipment was found.') + logger.warning('[装备-更换] 未找到记录的装备') self.ui_back(check_button=globals()[f'EQUIP_TAKE_ON_{index}'], appear_button=EQUIPPING_OFF) break diff --git a/module/equipment/equipment_code.py b/module/equipment/equipment_code.py index d895df497..4a0f47d3c 100644 --- a/module/equipment/equipment_code.py +++ b/module/equipment/equipment_code.py @@ -1,3 +1,7 @@ +"""装备码(配装码)解析与应用模块。 +支持从游戏内读取 Base64 编码的装备方案码, +并通过 UI 自动化将方案中指定的装备应用到舰船上。""" + import re import yaml @@ -50,7 +54,7 @@ def _code_config_load(self): if item: config.update(item) except Exception: - logger.error("Fail to load equipment code config") + logger.error("加载装备码配置失败") return config def _code_config_save(self, config): @@ -61,7 +65,7 @@ def _code_config_save(self, config): elif hasattr(self.config, 'EquipmentCode_Config'): self.config.EquipmentCode_Config = value else: - logger.warning("No equipment code config target, skip saving") + logger.warning("无装备码配置目标,跳过保存") def equipment_code_supported(self): method = self.config.Emulator_ControlMethod @@ -78,7 +82,7 @@ def get_code(self, name): config = self._code_config_load() code = config.get(name) if code is None: - logger.error(f"Config does not contain equipment code for {name}") + logger.error(f"[装备-代码] 配置不包含 {name} 的装备代码") return code def set_code(self, name, code): @@ -87,7 +91,7 @@ def set_code(self, name, code): config.update({name: code}) self._code_config_save(config) except Exception: - logger.error("Fail to set equipment code config") + logger.error("设置装备码配置失败") def current_ship(self): """ @@ -100,19 +104,19 @@ def current_ship(self): if not self.appear(EMPTY_SHIP_R): break if TEMPLATE_BOGUE.match(self.device.image, scaling=1.46): # image has rotation - logger.info("Bogue detected") + logger.info("检测到博格") return 'bogue' elif TEMPLATE_HERMES.match(self.device.image, scaling=124 / 89): - logger.info("Hermes detected") + logger.info("检测到竞技神") return 'hermes' elif TEMPLATE_RANGER.match(self.device.image, scaling=4 / 3): - logger.info("Ranger detected") + logger.info("检测到突击者") return 'ranger' elif TEMPLATE_LANGLEY.match(self.device.image, scaling=25 / 21): - logger.info("Langley detected") + logger.info("检测到兰利") return 'langley' else: - logger.warning("Unknown ship detected, assuming DD") + logger.warning("检测到未知舰船,假设为驱逐舰") return 'DD' def _code_enter(self): @@ -162,7 +166,7 @@ def fastinput_ime_enable(self): timeout = Timer(10).start() while 1: if timeout.reached(): - logger.warning("Enable FastInputIME timeout") + logger.warning("启用FastInputIME超时") break h = self.device.dump_hierarchy_adb() @@ -196,7 +200,7 @@ def set_fastinput_ime(self): try: d.set_fastinput_ime(True) except Exception: - logger.warning("FastInputIME not enabled, trying to enable it") + logger.warning("[装备-代码] FastInputIME未启用,尝试启用") self.fastinput_ime_enable() @staticmethod @@ -233,7 +237,7 @@ def _code_input_uiautomator2(self, code): return False def _code_input(self, code): - logger.info(f"Code input: {code}") + logger.info(f"代码输入: {code}") for _ in range(2): click_timer = Timer(1, count=3) textbox_clicked = False @@ -259,11 +263,11 @@ def _code_input(self, code): if self.appear_then_click(EQUIPMENT_CODE_ENTER, offset=(5, 5), interval=3): continue - logger.warning("Equipment code load failed") + logger.warning("装备码加载失败") return False def _code_confirm(self): - logger.info("Code apply") + logger.info("代码应用") for _ in self.loop(timeout=10): if self.appear(EQUIPMENT_CODE_ENTRANCE, offset=(5, 5)): return True @@ -285,7 +289,7 @@ def _code_apply(self, code=None): continue success = self._code_confirm() if success: - logger.info("Equipment code apply complete.") + logger.info("装备码应用完成") return True else: self.handle_storage_full() diff --git a/module/equipment/fleet_equipment.py b/module/equipment/fleet_equipment.py index 30b0e96da..a04a57777 100644 --- a/module/equipment/fleet_equipment.py +++ b/module/equipment/fleet_equipment.py @@ -1,3 +1,7 @@ +"""舰队装备管理模块,提供按舰队维度进行装备操作的功能。 +支持切换舰队、进入舰船详情页面,并结合装备更换逻辑 +完成整个舰队的批量装备更换。""" + from module.logger import logger from module.base.timer import Timer from module.campaign.campaign_base import CampaignBase @@ -25,7 +29,7 @@ def fleet_enter(self, fleet): retry = Timer(1, count=2) for _ in self.loop(): current = letter.ocr(self.device.image) - logger.attr("Index", current) + logger.attr("索引", current) # ui_ensure_index but ignore default value 0 # otherwise we would have 1 extra click switching from 1 to 4 diff --git a/module/event/assets.py b/module/event/assets.py index 7bd468d10..106dad63f 100644 --- a/module/event/assets.py +++ b/module/event/assets.py @@ -1,3 +1,5 @@ +"""活动模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/event/base.py b/module/event/base.py index e5055720c..d556c961c 100644 --- a/module/event/base.py +++ b/module/event/base.py @@ -1,3 +1,16 @@ +"""活动战役基础模块。 + +提供活动关卡的基类和通用工具,供 CampaignABCD、CampaignSP 等子类继承。 + +主要功能: +- EventStage: 从活动目录中的 .py 文件名提取关卡名称 +- EventBase: 活动战役基类,提供关卡名称转换和过滤功能 +- STAGE_FILTER: 基于正则的关卡过滤器,用于用户自定义关卡选择 + +活动地图文件存放在 campaign/{event_name}/ 目录下, +每个 .py 文件对应一个关卡(如 a1.py, b1.py, sp.py)。 +""" + import re from module.base.filter import Filter diff --git a/module/event/campaign_abcd.py b/module/event/campaign_abcd.py index 17a78784d..ae2fef9ac 100644 --- a/module/event/campaign_abcd.py +++ b/module/event/campaign_abcd.py @@ -1,3 +1,15 @@ +"""活动日常关卡(ABCD 等非 SP 关卡)执行模块。 + +按配置过滤器排序依次执行活动关卡,支持从上次中断的关卡继续执行。 +每个关卡每日执行 1 次,全部完成后延迟到次日服务器刷新。 + +关卡扫描自 campaign/{event_name}/ 目录,通过 EventDaily_StageFilter 配置 +筛选和排序要执行的关卡。执行进度通过 EventDaily_LastStage 持久化, +支持跨任务会话的断点续刷。 + +配置路径: EventDaily.StageFilter (关卡过滤器) +""" + import os from module.config.config import TaskEnd @@ -26,23 +38,23 @@ def run(self, *args, **kwargs): # 扫描活动目录下的所有地图文件 stages = [EventStage(file) for file in os.listdir(f'./campaign/{self.config.Campaign_Event}')] stages = self.convert_stages(stages) - logger.attr('Stage', [str(stage) for stage in stages]) - logger.attr('StageFilter', self.config.EventDaily_StageFilter) + logger.attr('关卡', [str(stage) for stage in stages]) + logger.attr('关卡过滤器', self.config.EventDaily_StageFilter) STAGE_FILTER.load(self.config.EventDaily_StageFilter) self.convert_stages(STAGE_FILTER) stages = [str(stage) for stage in STAGE_FILTER.apply(stages)] - logger.attr('Filter sort', ' > '.join(stages)) + logger.attr('过滤排序', ' > '.join(stages)) # 过滤后无可用关卡,禁用调度器并停止任务 if not stages: - logger.warning('No stage satisfy current filter') + logger.warning('无关卡满足当前筛选') self.config.Scheduler_Enable = False self.config.task_stop() # 从上次执行到的关卡继续,避免重复刷已完成的关卡 - logger.info(f'LastStage {self.config.EventDaily_LastStage}, recorded at {self.config.Scheduler_NextRun}') + logger.info(f'[活动-关卡] 上次关卡 {self.config.EventDaily_LastStage},记录于 {self.config.Scheduler_NextRun}') if get_server_last_update(self.config.Scheduler_ServerUpdate) >= self.config.Scheduler_NextRun: - logger.info('LastStage outdated, reset') + logger.info('[活动-关卡] 上次关卡记录过期,重置') self.config.EventDaily_LastStage = 0 else: last = str(self.config.EventDaily_LastStage).lower() @@ -50,9 +62,9 @@ def run(self, *args, **kwargs): if last in stages: # 跳到上次关卡之后的下一个关卡 stages = stages[stages.index(last) + 1:] - logger.attr('Filter sort', ' > '.join(stages)) + logger.attr('过滤排序', ' > '.join(stages)) else: - logger.info('Start from the beginning') + logger.info('从头开始') # 依次执行每个关卡 for stage in stages: diff --git a/module/event/campaign_sp.py b/module/event/campaign_sp.py index b115da17e..f145d2b29 100644 --- a/module/event/campaign_sp.py +++ b/module/event/campaign_sp.py @@ -1,3 +1,14 @@ +"""活动 SP 关卡执行模块。 + +执行活动的 SP(Special)关卡,每日限定 1 次。 +SP 关卡通常需要完成所有前置关卡后才可解锁,难度较高但奖励丰厚。 + +流程简单:检查 sp.py 地图文件是否存在 → 执行 1 次 → 延迟到次日。 +如果活动没有 SP 关卡或已执行过,自动延迟到次日服务器刷新。 + +配置路径: Campaign.Name (战役名称) +""" + import os from module.config.config import TaskEnd @@ -23,8 +34,8 @@ def run(self, *args, **kwargs): """ # 检查当前活动是否包含 SP 关卡 if not os.path.exists(f'./campaign/{self.config.Campaign_Event}/sp.py'): - logger.info(f'./campaign/{self.config.Campaign_Event}/sp.py not exists') - logger.info(f'This event do not have SP, skip') + logger.info(f'[活动-SP] ./campaign/{self.config.Campaign_Event}/sp.py 不存在') + logger.info(f'此活动无SP,跳过') self.config.Scheduler_Enable = False self.config.task_stop() @@ -35,18 +46,18 @@ def run(self, *args, **kwargs): pass except RequestHumanTakeover: # 每日 SP 已完成或无法进入,延迟到次日 - logger.info('Daily SP already completed or unable to enter') - logger.info('Delaying task to next day') + logger.info('每日SP已完成或无法进入') + logger.info('延迟任务到明天') self.config.task_delay(server_update=True) return # 根据执行结果决定后续调度 if self.run_count > 0: # SP 执行成功,延迟到次日服务器刷新 - logger.info(f'SP completed successfully, run_count={self.run_count}') + logger.info(f'已完成, run_count={self.run_count}') self.config.task_delay(server_update=True) else: # SP 未成功执行(可能今日已完成),延迟到次日而非停止 - logger.info('SP failed to execute, possibly already completed today') - logger.info('Delaying task to next day') + logger.info('执行失败,可能今天已完成') + logger.info('延迟任务到明天') self.config.task_delay(server_update=True) diff --git a/module/event/maritime_escort.py b/module/event/maritime_escort.py index 854b2f556..4d0826964 100644 --- a/module/event/maritime_escort.py +++ b/module/event/maritime_escort.py @@ -1,3 +1,10 @@ +"""海上护卫活动执行模块。 + +实现海上护卫活动的自动化执行。进入关卡后立即撤退, +以较低消耗获取约 70% 的最大奖励。使用 OCR 识别剩余 +挑战次数,每日自动执行直至次数耗尽。 +""" + from module.campaign.campaign_event import CampaignEvent from module.event.assets import * from module.exception import CampaignEnd @@ -45,14 +52,14 @@ def run_escort(self): in: ESCORT_CHECK out: ESCORT_CHECK """ - logger.hr('Maritime escort', level=1) + logger.hr('海上护卫', level=1) try: self.enter_map(ESCORT_HARD_ENTRANCE, mode='escort') self.withdraw() except CampaignEnd: pass - logger.info('Maritime escort finished') + logger.info('[活动-护航] 海上护卫完成') def run(self): """执行海上护卫主流程。 @@ -72,6 +79,6 @@ def run(self): if current > 0: self.run_escort() else: - logger.info('Maritime escort already finished') + logger.info('[活动-护航] 海上护卫今日已完成') self.config.task_delay(server_update=True) diff --git a/module/event_hospital/assets.py b/module/event_hospital/assets.py index 55fa9fdf7..c35d94460 100644 --- a/module/event_hospital/assets.py +++ b/module/event_hospital/assets.py @@ -1,3 +1,5 @@ +"""医院活动模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/event_hospital/clue.py b/module/event_hospital/clue.py index 48e1a1e1f..28b18a462 100644 --- a/module/event_hospital/clue.py +++ b/module/event_hospital/clue.py @@ -1,3 +1,10 @@ +"""医院活动线索识别模块。 + +通过图像处理和 OCR 识别医院活动中的线索信息。包含矩形 +合并、文本行聚类等图像预处理逻辑,用于从线索界面截图中 +提取并解析线索文本内容。 +""" + from functools import reduce from typing import List, Optional, Tuple @@ -153,7 +160,7 @@ def clue_enter(self, skip_first_screenshot=True): in: 医院活动任意子页面 out: is_in_clue """ - logger.info('Hospital clue enter') + logger.info('进入医院线索') self.interval_clear(page_hospital.check_button) while 1: if skip_first_screenshot: @@ -172,7 +179,7 @@ def clue_exit(self, skip_first_screenshot=True): in: 医院活动任意子页面 out: page_hospital """ - logger.info('Hospital clue exit') + logger.info('退出医院线索') self.interval_clear(HOSIPITAL_CLUE_CHECK) while 1: if skip_first_screenshot: @@ -201,7 +208,7 @@ def invest_enter(self, skip_first_screenshot=True): in: is_in_clue out: FLEET_PREPARATION """ - logger.info('Clue invest') + logger.info('线索投资') self.interval_clear(HOSIPITAL_CLUE_CHECK) while 1: if skip_first_screenshot: @@ -214,9 +221,9 @@ def invest_enter(self, skip_first_screenshot=True): if self.is_in_clue(interval=2): invest = next(self.iter_invest(), None) if invest is None: - logger.info('No more invest') + logger.info('无更多投资') return False - logger.info(f'is_in_clue -> {invest}') + logger.info(f'线索中 -> {invest}') self.device.click(invest) self.interval_reset(HOSIPITAL_CLUE_CHECK, interval=2) continue @@ -233,11 +240,11 @@ def iter_invest(self): Yields: Button: 未完成的调查按钮。 """ - logger.hr('Iter invest') + logger.hr('迭代投资') scroll = Scroll(INVEST_SCROLL, color=(107, 97, 107), name='INVEST_SCROLL') # 无滚动条时只检查当前页 if not scroll.appear(main=self): - logger.info('No scroll') + logger.info('无滚动条') button = self.get_invest_button() if button: yield button @@ -257,7 +264,7 @@ def iter_invest(self): # 逐页遍历 while 1: if scroll.at_bottom(main=self): - logger.info(f'{scroll.name} reached end') + logger.info(f'{scroll.name} 到达终点') return scroll.next_page(main=self, page=0.5) button = self.get_invest_button() @@ -308,7 +315,7 @@ def select_aside(self, skip_first_screenshot=True): Pages: in: is_in_clue """ - logger.info(f'Select aside') + logger.info(f'选择旁白') aside = None self.interval_clear(HOSIPITAL_CLUE_CHECK) while 1: @@ -322,9 +329,9 @@ def select_aside(self, skip_first_screenshot=True): if self.is_in_clue(interval=2): aside = next(self.iter_aside(), None) if aside is None: - logger.info('No more aside') + logger.info('无更多旁白') return False - logger.info(f'is_in_clue -> {aside}') + logger.info(f'线索中 -> {aside}') self.device.click(aside) self.interval_reset(HOSIPITAL_CLUE_CHECK, interval=2) continue diff --git a/module/event_hospital/combat.py b/module/event_hospital/combat.py index 7d118dc6f..54e3f4129 100644 --- a/module/event_hospital/combat.py +++ b/module/event_hospital/combat.py @@ -1,3 +1,10 @@ +"""医院活动战斗处理模块。 + +处理医院活动中的战斗流程,包括舰队推荐编队、战斗准备、 +战斗执行和结果处理。继承 Combat、HospitalUI 和 +CampaignEvent,组合完整的战斗生命周期管理。 +""" + from module.base.decorator import run_once from module.base.timer import Timer from module.campaign.campaign_event import CampaignEvent @@ -36,12 +43,11 @@ def handle_fleet_recommend(self, recommend=True): return False if recommend: - logger.info('Recommend fleet') + logger.info('推荐舰队') fleet_1.recommend() return True else: - logger.error('Fleet not prepared and fleet recommend is not enabled, ' - 'please prepare fleets manually before running') + logger.error('[医院-战斗] 舰队未就绪且未启用自动推荐,请在运行前手动编队') raise RequestHumanTakeover def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='combat_auto', fleet_index=1): @@ -53,23 +59,23 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba auto: 自动战斗模式。 fleet_index: 舰队索引。 """ - logger.info('Combat preparation.') + logger.info('战斗准备。') skip_first_screenshot = True @run_once def check_oil(): if self.get_oil() < max(500, self.config.StopCondition_OilLimit): - logger.hr('Triggered oil limit') + logger.hr('触发石油上限') raise OilExhausted @run_once def check_coin(): if self.coin_limit_triggered(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') self.config.task_stop() return True if self.config.TaskBalancer_Enable and self.triggered_task_balancer(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') self.handle_task_balancer() return True @@ -103,7 +109,7 @@ def check_coin(): # 战斗开始 pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) if emotion_reduce: self.emotion.reduce(fleet_index) break diff --git a/module/event_hospital/hospital.py b/module/event_hospital/hospital.py index 7d7654ac2..c38a2f008 100644 --- a/module/event_hospital/hospital.py +++ b/module/event_hospital/hospital.py @@ -1,3 +1,17 @@ +"""医院活动模块。 + +提供碧蓝航线医院活动的自动化处理功能,包括: +- 每日奖励的红点检测与自动领取 +- 线索系统的标签页切换(地点 / 角色) +- 旁白(aside)列表的遍历与选择 +- 调查(invest)入口的进入与战斗执行 +- 调查奖励的自动领取 +- 旁白列表的滑动翻页处理 +- 体力不足时的优雅退出与延迟重试 + +医院活动是一个探索型活动,玩家通过选择不同地点和角色的旁白展开调查, +每个调查包含线索收集和战斗环节。 +""" from module.base.timer import Timer from module.base.utils import random_rectangle_vector from module.config.config import TaskEnd @@ -37,7 +51,21 @@ def get(self, main): class Hospital(HospitalClue, HospitalCombat): - """医院活动主控制器,组合线索和战斗逻辑。""" + """医院活动主控制器。 + + 组合线索处理(HospitalClue)和战斗处理(HospitalCombat)能力, + 实现医院活动的完整自动化流程。 + + 工作流程: + 1. 检查活动可用性,导航至活动页面 + 2. 领取每日奖励(检测红点 -> 进入奖励界面 -> 领取 -> 退出) + 3. 进入线索系统,遍历地点和角色标签页的所有旁白 + 4. 对每个旁白执行调查(进入 -> 战斗 -> 领取奖励) + 5. 角色标签页支持滑动翻页以访问更多旁白 + + Attributes: + HOSPITAL_TAB (HospitalSwitch): 标签页切换器,支持 LOCATION 和 CHARACTER 两种状态。 + """ def daily_red_dot_appear(self): """检测每日奖励红点是否出现。""" @@ -63,14 +91,14 @@ def daily_reward_receive(self): in: page_hospital """ if self.daily_red_dot_appear(): - logger.info('Daily red dot appear') + logger.info('每日红点出现') else: - logger.info('No daily red dot') + logger.info('无每日红点') return False - logger.hr('Daily reward receive', level=2) + logger.hr('领取每日奖励', level=2) # 进入奖励界面 - logger.info('Daily reward enter') + logger.info('进入每日奖励') skip_first_screenshot = True self.interval_clear(page_hospital.check_button) while 1: @@ -86,7 +114,7 @@ def daily_reward_receive(self): continue # 领取奖励 - logger.info('Daily reward receive') + logger.info('领取每日奖励') skip_first_screenshot = True self.interval_clear(HOSIPITAL_CLUE_CHECK) timeout = Timer(1.5, count=6).start() @@ -97,7 +125,7 @@ def daily_reward_receive(self): else: self.device.screenshot() if timeout.reached(): - logger.warning('Daily reward receive timeout') + logger.warning('每日奖励领取超时') break if clicked and self.is_in_daily_reward(): if not self.daily_reward_receive_appear(): @@ -112,7 +140,7 @@ def daily_reward_receive(self): continue # 退出奖励界面 - logger.info('Daily reward exit') + logger.info('退出每日奖励') skip_first_screenshot = True self.interval_clear(HOSIPITAL_CLUE_CHECK) while 1: @@ -137,7 +165,7 @@ def loop_invest(self): """ self.config.override(Fleet_FleetOrder='fleet1_all_fleet2_standby') while 1: - logger.hr('Loop hospital invest', level=2) + logger.hr('循环医院投资', level=2) # 调度器检查,可能抛出 ScriptEnd self.emotion.check_reduce(battle=1) @@ -154,7 +182,7 @@ def loop_invest(self): break self.claim_invest_reward() - logger.info('Loop hospital invest end') + logger.info('循环医院投资 end') def invest_reward_appear(self) -> bool: """检测调查奖励领取按钮是否出现。""" @@ -163,9 +191,9 @@ def invest_reward_appear(self) -> bool: def claim_invest_reward(self): """领取调查奖励。""" if self.invest_reward_appear(): - logger.info('Invest reward appear') + logger.info('投资奖励出现') else: - logger.info('No invest reward') + logger.info('无投资奖励') return False # 领取奖励 skip_first_screenshot = True @@ -191,7 +219,7 @@ def claim_invest_reward(self): def loop_aside(self): """遍历所有标签页的旁白并执行调查。""" while 1: - logger.hr('Loop hospital aside', level=1) + logger.hr('循环医院旁白', level=1) HOSPITAL_TAB.set('LOCATION', main=self) selected = self.select_aside() if not selected: @@ -199,7 +227,7 @@ def loop_aside(self): self.loop_invest() while 1: - logger.hr('Loop hospital aside', level=1) + logger.hr('循环医院旁白', level=1) HOSPITAL_TAB.set('CHARACTER', main=self) selected = self.select_aside() if not selected: @@ -207,7 +235,7 @@ def loop_aside(self): self.loop_invest() while 1: - logger.hr('Loop hospital aside', level=1) + logger.hr('循环医院旁白', level=1) HOSPITAL_TAB.set('CHARACTER', main=self) self.aside_swipe_down() selected = self.select_aside() @@ -215,11 +243,11 @@ def loop_aside(self): break self.loop_invest() - logger.info('Loop hospital aside end') + logger.info('循环医院旁白 end') def aside_swipe_down(self, skip_first_screenshot=True): """向下滑动旁白列表直到没有翻页标识。""" - logger.info('Aside swipe down') + logger.info('旁白下滑') swiped = False interval = Timer(2, count=6) while 1: @@ -229,7 +257,7 @@ def aside_swipe_down(self, skip_first_screenshot=True): self.device.screenshot() if swiped and not self.appear(ASIDE_NEXT_PAGE, offset=(20, 20)): - logger.info('Aside reached end') + logger.info('旁白到达终点') break if interval.reached(): p1, p2 = random_rectangle_vector( @@ -259,10 +287,10 @@ def run(self): self.config.task_delay(server_update=True) except OilExhausted: self.clue_exit() - logger.hr('Triggered stop condition: Oil limit') + logger.hr('触发停止条件: 石油上限') self.config.task_delay(minute=(120, 240)) except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) self.clue_exit() except TaskEnd: diff --git a/module/event_hospital/hospital_event.py b/module/event_hospital/hospital_event.py index 4852914c7..75ea76428 100644 --- a/module/event_hospital/hospital_event.py +++ b/module/event_hospital/hospital_event.py @@ -1,3 +1,10 @@ +"""医院活动事件主控模块。 + +医院活动的顶层任务处理器,管理活动入口导航、难度选择、 +关卡进入和战斗编排。继承 Hospital 和 RaidRun,复用 +突袭任务的运行框架来驱动医院活动的完整执行流程。 +""" + from module.campaign.campaign_ui import ModeSwitch from module.combat.assets import BATTLE_PREPARATION from module.event_hospital.assets import * @@ -30,7 +37,7 @@ def campaign_ensure_aside_hospital(self, chapter): if chapter in ['easy', 'normal', 'hard']: ASIDE_SWITCH_HOSPITAL.set(chapter, main=self) else: - logger.warning(f'Unknown campaign aside: {chapter}') + logger.warning(f'未知的战役旁白: {chapter}') def hospital_expected_end(self): """判断医院活动战斗是否结束(突袭模式)。 @@ -106,7 +113,7 @@ def raid_execute_once(self, mode, raid, stage): in: page_raid out: page_raid """ - logger.hr('Raid Execute') + logger.hr('突袭执行') self.config.override( Campaign_Event=raid, Campaign_Name=f'{raid}_{mode}_{stage}', @@ -120,7 +127,7 @@ def raid_execute_once(self, mode, raid, stage): self.raid_enter(stage=stage, raid=raid) self.hospital_combat() - logger.hr('Raid End') + logger.hr('突袭结束') def run(self, name='', mode='', stage='', total=0): """医院活动突袭主入口。 @@ -149,9 +156,9 @@ def run(self, name='', mode='', stage='', total=0): # 日志 logger.hr(f'{name}_{mode}_{stage}', level=2) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'次数: {self.run_count}') # 停止条件检查 if self.triggered_stop_condition(): @@ -169,11 +176,11 @@ def run(self, name='', mode='', stage='', total=0): self.campaign_ensure_aside_hospital(chapter=mode) self.raid_execute_once(mode=mode, raid=name, stage=stage) except OilExhausted: - logger.hr('Triggered stop condition: Oil limit') + logger.hr('触发停止条件: 石油上限') self.config.task_delay(minute=(120, 240)) break except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) break diff --git a/module/event_hospital/ui.py b/module/event_hospital/ui.py index 3da53f237..16ea5e84a 100644 --- a/module/event_hospital/ui.py +++ b/module/event_hospital/ui.py @@ -1,3 +1,10 @@ +"""医院活动 UI 处理模块。 + +提供医院活动的界面交互功能,包括线索界面检测、 +线索获取弹窗处理、战斗准备页面检测以及返回导航等 +UI 操作,是医院活动各子模块的基础 UI 依赖。 +""" + from module.combat.assets import BATTLE_PREPARATION from module.event_hospital.assets import * from module.logger import logger diff --git a/module/eventstory/assets.py b/module/eventstory/assets.py index df477737a..b55a9dcfc 100644 --- a/module/eventstory/assets.py +++ b/module/eventstory/assets.py @@ -1,3 +1,5 @@ +"""活动剧情模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/eventstory/eventstory.py b/module/eventstory/eventstory.py index bd26d0126..68f556688 100644 --- a/module/eventstory/eventstory.py +++ b/module/eventstory/eventstory.py @@ -1,3 +1,15 @@ +"""活动剧情模块。 + +提供碧蓝航线活动剧情的自动化推进功能,包括: +- 活动剧情入口导航(常规活动页面 / SP 活动页面) +- 剧情状态检测(未完成 / 已完成 / 未知) +- 剧情推进的截图-检查循环(首段 / 末段 / 中段 / 战斗中段) +- 剧情跳过与奖励领取处理 +- 战斗场景的自动重启跳过(比等待战斗结束更快) +- 特殊活动剧情按钮的模板匹配(如炼金术士联动活动) + +活动结束后自动返回主界面以清理残留弹窗。 +""" from module.base.timer import Timer from module.base.utils import rgb2gray, lower_template_match_similarity from module.campaign.campaign_ui import CampaignUI @@ -9,6 +21,24 @@ class EventStory(CampaignUI, Combat, LoginHandler): + """活动剧情自动化控制器。 + + 组合战役 UI 导航、战斗处理和登录处理能力,实现活动剧情的自动推进。 + + 工作流程: + 1. 导航至活动剧情入口页面 + 2. 检测剧情状态(完成 / 可进入 / 未知) + 3. 循环执行截图-检查循环推进剧情 + 4. 遇到战斗场景时重启游戏跳过(比等待战斗结束更快) + 5. 剧情完成后返回主界面清理残留弹窗 + + 支持的剧情阶段: + - STORY_FIRST:首段剧情入口 + - STORY_LAST:末段剧情 + - STORY_MIDDLE:中段剧情 + - BATTLE_MIDDLE:战斗中段剧情 + - 特殊活动按钮(如炼金术士联动活动的弹出式剧情按钮) + """ def ui_goto_event_story(self): """ 导航至活动剧情入口并获取当前剧情状态。 @@ -37,11 +67,11 @@ def ui_goto_event_story(self): timeout = Timer(2, count=6).start() for _ in self.loop(): state = self.get_event_story_state() - logger.attr('EventStoryState', state) + logger.attr('活动剧情状态', state) if state != 'unknown': break if timeout.reached(): - logger.warning('Wait EventStoryState timeout') + logger.warning('等待活动剧情状态超时') break if state == 'unknown': # 剧情页面被滑动过,找不到剧情入口 @@ -125,7 +155,7 @@ def event_story(self, skip_first_screenshot=True): Returns: str: 'battle'(进入战斗)或 'finish'(剧情结束) """ - logger.hr('Event story', level=1) + logger.hr('活动剧情', level=1) while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -134,13 +164,13 @@ def event_story(self, skip_first_screenshot=True): # 结束条件检测 if self.is_combat_executing() or self.is_combat_loading(): - logger.info('run_story end at battle') + logger.info('[活动剧情] 剧情结束于战斗') return 'battle' if self.match_template_color(STORY_FINISHED, offset=(20, 20), interval=3): - logger.info('run_story end at STORY_FINISHED') + logger.info('[活动剧情] 剧情结束于 STORY_FINISHED') return 'finish' if self.appear(REWARD_GOT, offset=(50, 30)): - logger.info('run_story end at REWARD_GOT') + logger.info('[活动剧情] 剧情结束于 REWARD_GOT') return 'finish' # 剧情跳过处理 @@ -200,7 +230,7 @@ def run_event_story(self): result = self.event_story() if result == 'battle': # 通过重启游戏跳过活动战斗,比等待战斗结束快得多 - logger.hr('Event Story Battle', level=2) + logger.hr('活动剧情战斗', level=2) self.config.override(Error_HandleError=True) self.app_stop() self.device.sleep(2) @@ -208,7 +238,7 @@ def run_event_story(self): continue if result == 'finish': # 剧情结束后返回主界面再进入,以关闭可能残留的 GET_ITEMS 弹窗 - logger.hr('Event story finish', level=2) + logger.hr('活动剧情 finish', level=2) self.ui_goto_main() self.ui_goto_event_story() @@ -260,11 +290,11 @@ def run(self): # 该活动的剧情入口在活动小游戏内,不在常规剧情页面 'event_20260226_cn', ]: - logger.info(f'Current event ({event}) does not have event story, stopped') + logger.info(f'[活动剧情] 当前活动 ({event}) 没有活动剧情,停止') return if not self.device.app_is_running(): - logger.warning('Game is not running, start it') + logger.warning('游戏未运行,启动中') self.app_start() self.run_event_story() diff --git a/module/exception.py b/module/exception.py index 4ccbf1861..8eab01999 100644 --- a/module/exception.py +++ b/module/exception.py @@ -1,75 +1,186 @@ +"""异常层次结构。 + +定义 AzurPilot 的所有自定义异常。异常按严重程度和可恢复性分为以下层次: + +正常战役结束(可预期的流程终止): + - CampaignEnd: 战役正常结束(回到关卡选择页面) + - OilExhausted: 石油耗尽,无法继续出击 + - OilMaxed: 石油已达上限,需要消耗 + +地图导航错误(可恢复,触发重试): + - MapDetectionError: 地图透视检测失败(网格识别错误) + - MapWalkError: 舰队行走错误(目标不可达) + - MapEnemyMoved: 敌人移动导致路径失效,需要重新规划 + - CampaignNameError: 关卡名称无法识别 + +游戏状态错误(触发模拟器重启): + - GameStuckError: 游戏卡死(1 分钟内无有效操作,战斗期间 5 分钟) + - GameBugError: 游戏客户端 bug(重启通常可恢复) + - GameTooManyClickError: 点击次数过多(15 次操作中同一按钮被点 ≥12 次) + +连接/页面错误(触发重启或人工干预): + - GameNotRunningError: 游戏未运行 + - GamePageUnknownError: 无法识别当前页面 + - EmulatorNotRunningError: 模拟器未运行 + +开发者/脚本错误: + - ScriptError: 脚本逻辑错误(通常是代码 bug) + - ScriptEnd: 脚本正常结束(用于中断当前任务流程) + +不可恢复错误(需要人工干预): + - RequestHumanTakeover: 请求人工接管(配置错误等严重问题) + - AutoSearchSetError: 自动搜索设置失败 + - HardNotSatisfied: 困难模式前置条件不满足 +""" + + class CampaignEnd(Exception): + """战役正常结束。 + + 当舰队回到关卡选择页面时抛出,由调度循环捕获并处理后续逻辑 + (如推进关卡、记录掉落等)。 + """ pass class OilExhausted(Exception): + """石油耗尽。 + + 当战斗准备界面检测到石油不足时抛出,结束当前战役任务。 + """ pass class OilMaxed(Exception): + """石油已达上限。 + + 当石油溢出时抛出,提示需要消耗石油以免浪费。 + """ pass class MapDetectionError(Exception): + """地图透视检测失败。 + + 当无法从截图中正确解析地图网格信息时抛出。 + 可能原因:信息栏遮挡、弹窗干扰、游戏画面异常。 + """ pass class MapWalkError(Exception): + """舰队行走错误。 + + 当舰队无法到达目标格子时抛出,可能原因包括路径被阻断、步数不足等。 + """ pass class MapEnemyMoved(Exception): + """敌人移动导致路径失效。 + + 在可移动敌人系统中,敌人回合移动后当前路径可能不再有效, + 需要重新规划路径。此异常用于中断当前行走并触发重新寻路。 + """ pass class CampaignNameError(Exception): + """关卡名称无法识别。 + + 当配置中的关卡名称无法映射到有效的地图文件时抛出。 + """ pass class ScriptError(Exception): - # 通常是开发者的代码错误,但也可能是偶发的随机问题 + """脚本逻辑错误。 + + 通常是开发者的代码错误,但也可能是偶发的随机问题。 + 触发模拟器重启尝试恢复。 + """ pass class ScriptEnd(Exception): + """脚本正常结束。 + + 用于中断当前任务流程(如情绪不足需要延迟), + 不视为错误,不计入失败次数。 + """ pass class GameStuckError(Exception): + """游戏卡死。 + + 1 分钟内无有效截图操作(战斗/启动期间为 5 分钟)时触发。 + 通常通过重启模拟器恢复。 + """ pass class GameBugError(Exception): - # 碧蓝航线游戏客户端发生错误,AzurPilot 无法自行处理。 - # 通常重启游戏即可恢复。 + """碧蓝航线游戏客户端发生错误,AzurPilot 无法自行处理。 + + 通常重启游戏即可恢复。 + """ pass class GameTooManyClickError(Exception): + """点击次数过多,疑似陷入循环。 + + 最近 15 次操作中,同一按钮被点击 ≥12 次, + 或两个按钮各被点击 ≥6 次时触发。 + """ pass class EmulatorNotRunningError(Exception): + """模拟器未运行。 + + 当 ADB 连接失败且模拟器进程不存在时抛出。 + """ pass class GameNotRunningError(Exception): + """游戏未运行。 + + 当截图为黑屏或游戏进程已退出时抛出。 + """ pass class GamePageUnknownError(Exception): + """无法识别当前游戏页面。 + + 当页面检测逻辑无法匹配任何已知页面时抛出。 + """ pass class RequestHumanTakeover(Exception): - # 请求人工接管 - # AzurPilot 无法处理此类错误,可能是由于配置错误导致。 + """请求人工接管。 + + AzurPilot 无法处理此类错误,可能是由于配置错误导致。 + 需要用户手动检查并修正问题。 + """ pass class AutoSearchSetError(Exception): + """自动搜索设置失败。 + + 当无法正确设置自动搜索选项时抛出(如舰队准备界面异常)。 + """ pass class HardNotSatisfied(RequestHumanTakeover): + """困难模式前置条件不满足。 + + 继承自 RequestHumanTakeover,当困难关卡的前置条件未满足时抛出。 + """ pass diff --git a/module/exercise/assets.py b/module/exercise/assets.py index fcb325b41..57dad7866 100644 --- a/module/exercise/assets.py +++ b/module/exercise/assets.py @@ -1,3 +1,5 @@ +"""演习模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/exercise/combat.py b/module/exercise/combat.py index 12e65ba1c..925bad272 100644 --- a/module/exercise/combat.py +++ b/module/exercise/combat.py @@ -1,3 +1,15 @@ +""" +演习战斗执行模块。 + +处理演习的战斗流程,包括: +- 对手选择和进入战斗准备界面 +- 战斗执行和结算画面处理 +- 低血量检测和战斗退出处理 +- 装备管理(演习前穿装、演习后脱装) + +战斗过程中自动检测 S/D 评价结算、经验信息、获得物品等画面, +并通过弹窗处理器处理各类突发事件(紧急委托、投票等)。 +""" from module.combat.combat import * from module.exercise.assets import * from module.exercise.equipment import ExerciseEquipment @@ -7,11 +19,28 @@ class ExerciseCombat(HpDaemon, OpponentChoose, ExerciseEquipment, Combat): + """ + 演习战斗处理器,整合对手选择、血量监控和装备管理。 + + 继承自 HpDaemon(血量监控)、OpponentChoose(对手选择)、 + ExerciseEquipment(装备管理)和 Combat(战斗逻辑), + 提供完整的演习战斗执行流程。 + + 战斗流程:选择对手 -> 准备 -> 执行 -> 结算处理 -> 返回。 + """ + def _in_exercise(self): + """检测当前是否在演习主页面。""" return self.appear(EXERCISE_CHECK, offset=(20, 20)) def _combat_preparation(self, skip_first_screenshot=True): - logger.info('Combat preparation') + """ + 处理战斗准备界面,点击开始按钮进入战斗。 + + Args: + skip_first_screenshot (bool): 是否跳过首次截图。 + """ + logger.info('[演习-战斗] 战斗准备') self.device.stuck_record_clear() self.device.click_record_clear() while 1: @@ -30,7 +59,7 @@ def _combat_preparation(self, skip_first_screenshot=True): # 结束 pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI主题', pause) break def _combat_execute(self): @@ -40,7 +69,7 @@ def _combat_execute(self): Returns: bool: 胜利返回 True,退出返回 False。 """ - logger.info('Combat execute') + logger.info('[演习-战斗] 执行战斗') self.device.stuck_record_clear() self.device.click_record_clear() self.low_hp_confirm_timer = Timer(1.5, count=2).start() @@ -55,9 +84,9 @@ def _combat_execute(self): self.device.screenshot() # 结束 if self._in_exercise() or self.appear(BATTLE_PREPARATION, offset=(20, 20)): - logger.hr('Combat end') + logger.hr('战斗结束') if not end: - logger.warning('Combat ended without end conditions detected') + logger.warning('[演习-战斗] 战斗结束但未检测到结束条件') break p = self.is_combat_executing() if p: @@ -69,39 +98,39 @@ def _combat_execute(self): self.low_hp_confirm_timer.reset() # 结算 - S 或 D 评价 if self.appear(BATTLE_STATUS_S, interval=1): - logger.info(f'{BATTLE_STATUS_S} -> {CLICK_SAFE_AREA}') + logger.info(f'[演习-战斗] {BATTLE_STATUS_S} -> {CLICK_SAFE_AREA}') self.device.click(CLICK_SAFE_AREA) success = True end = True battle_status_detected = True continue if self.appear(BATTLE_STATUS_D, interval=1): - logger.info(f'{BATTLE_STATUS_D} -> {CLICK_SAFE_AREA}') + logger.info(f'[演习-战斗] {BATTLE_STATUS_D} -> {CLICK_SAFE_AREA}') self.device.click(CLICK_SAFE_AREA) success = True end = True battle_status_detected = True - logger.info("Exercise LOST") + logger.info('[演习-战斗] 演习失败') continue # 仅在战斗结算后处理 GET_ITEMS_1 if battle_status_detected and self.appear(GET_ITEMS_1, offset=(30, 30), interval=1): - logger.info(f'{GET_ITEMS_1} -> {CLICK_SAFE_AREA}') + logger.info(f'[演习-战斗] {GET_ITEMS_1} -> {CLICK_SAFE_AREA}') self.device.click(CLICK_SAFE_AREA) continue if self.appear(EXP_INFO_S, interval=1): - logger.info(f'{EXP_INFO_S} -> {CLICK_SAFE_AREA}') + logger.info(f'[演习-战斗] {EXP_INFO_S} -> {CLICK_SAFE_AREA}') self.device.click(CLICK_SAFE_AREA) continue if self.appear(EXP_INFO_D, interval=1): - logger.info(f'{EXP_INFO_D} -> {CLICK_SAFE_AREA}') + logger.info(f'[演习-战斗] {EXP_INFO_D} -> {CLICK_SAFE_AREA}') self.device.click(CLICK_SAFE_AREA) continue # 最后的 D 评价画面 if self.appear_then_click(OPTS_INFO_D, offset=(30, 30), interval=1): success = True end = True - logger.info("Exercise LOST") + logger.info('[演习-战斗] 演习失败') continue # 退出 if self.handle_combat_quit(): @@ -114,7 +143,7 @@ def _combat_execute(self): continue if not end: if p and self._at_low_hp(image=self.device.image, pause=pause): - logger.info('Exercise quit') + logger.info('[演习-战斗] 退出演习') if pause_interval.reached(): self.device.click(p) pause_interval.reset() @@ -143,7 +172,7 @@ def _choose_opponent(self, index, skip_first_screenshot=True): Args: index (int): 从左到右,0 到 3。 """ - logger.hr('Opponent: %s' % str(index)) + logger.hr('对手: %s' % str(index)) opponent_timer = Timer(5) preparation_timer = Timer(5) @@ -168,7 +197,8 @@ def _choose_opponent(self, index, skip_first_screenshot=True): break def _preparation_quit(self): - logger.info('Preparation quit') + """从战斗准备界面退回演习主页面。""" + logger.info('[演习-战斗] 退出准备界面') self.ui_back(check_button=self._in_exercise, appear_button=BATTLE_PREPARATION, skip_first_screenshot=True) def _combat(self, opponent): @@ -185,11 +215,11 @@ def _combat(self, opponent): trial = self.config.Exercise_OpponentTrial if not isinstance(trial, int) or trial < 1: - logger.warning(f'Invalid Exercise.OpponentTrial: {trial}, revise to 1') + logger.warning(f'[演习-战斗] 无效的对手尝试次数: {trial},修正为1') self.config.Exercise_OpponentTrial = 1 for n in range(1, self.config.Exercise_OpponentTrial + 1): - logger.hr('Try: %s' % n) + logger.hr('尝试: %s' % n) self._combat_preparation() success = self._combat_execute() if success: @@ -199,6 +229,7 @@ def _combat(self, opponent): return False def equipment_take_off_when_finished(self): + """演习结束后卸下装备。""" if self.config.EXERCISE_FLEET_EQUIPMENT is None: return False if not self.equipment_has_take_on: diff --git a/module/exercise/equipment.py b/module/exercise/equipment.py index d77429346..14d8c224d 100644 --- a/module/exercise/equipment.py +++ b/module/exercise/equipment.py @@ -1,3 +1,15 @@ +"""演习装备管理模块。 + +管理演习(PvP)中的装备编辑操作。 +在演习准备界面中可以切换舰船的装备配置。 + +功能: +- 激活/关闭装备编辑模式 +- 检测装备编辑按钮状态 + +继承自 Equipment 基类。 +""" + from module.base.timer import Timer from module.combat.assets import BATTLE_PREPARATION from module.equipment.equipment import Equipment @@ -5,6 +17,10 @@ class ExerciseEquipment(Equipment): + """演习装备编辑器。 + + 管理演习准备界面中的装备编辑按钮状态切换。 + """ def _active_edit(self): timer = Timer(5) while 1: diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index d9a7f056c..b9c55d2b4 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -1,3 +1,19 @@ +""" +演习(PvP)任务模块。 + +自动执行演习系统的日常操作,包括: +- 通过 OCR 识别剩余演习次数和赛季重置倒计时 +- 支持多种对手选择策略:按经验优先、按难度优先、最左优先等 +- 支持将军试炼时间区间配置,在特定时段集中消耗次数 +- 管理对手刷新次数,跨天自动重置 +- 支持延迟执行,在赛季结束前指定时间开始消耗 + +对手选择策略: +- max_exp: 选择经验最高的对手 +- easiest: 选择最容易击败的对手 +- easiest_else_exp: 优先选最简单的,无法击败时切换到最大经验 +- leftmost: 优先选择最左侧的对手 +""" import datetime from module.config.time_source import now as current_time from module.config.utils import get_server_last_update @@ -9,6 +25,18 @@ from module.config.utils import get_server_next_update class DatedDuration(Ocr): + """ + 带日期的时长 OCR 识别器。 + + 用于识别演习赛季剩余时间格式,如 `10d 01:30:30` 或 `7日01:30:30`。 + 对 OCR 常见错误进行修正(I->1, D->0, S->5)。 + + Attributes: + buttons: OCR 识别区域。 + lang (str): OCR 语言,默认 'cnocr'。 + alphabet (str): 可识别字符集。 + """ + def __init__(self, buttons, lang='cnocr', letter=(255, 255, 255), threshold=128, alphabet='0123456789:IDS天日d', name=None): super().__init__(buttons, lang=lang, letter=letter, threshold=threshold, alphabet=alphabet, name=name) @@ -54,11 +82,16 @@ def parse_time(string): result = [int(s) for s in result.groups()] return datetime.timedelta(days=result[0], hours=result[1], minutes=result[2], seconds=result[3]) else: - logger.warning(f'Invalid dated duration: {string}') + logger.warning(f'[演习-OCR] 无效的带日期时长: {string}') return datetime.timedelta(days=0, hours=0, minutes=0, seconds=0) class DatedDurationYuv(DatedDuration, OcrYuv): + """ + YUV 色彩空间的带日期时长 OCR 识别器。 + + 继承自 DatedDuration 和 OcrYuv,使用 YUV 色彩空间进行预处理。 + """ pass @@ -77,25 +110,57 @@ class DatedDurationYuv(DatedDuration, OcrYuv): class Exercise(ExerciseCombat): + """ + 演习任务主处理器,负责演习的调度和执行。 + + 继承自 ExerciseCombat,整合了对手选择、战斗执行、次数管理等功能。 + 根据配置的策略自动选择对手并执行战斗,支持多种消耗策略和延迟执行。 + + Attributes: + opponent_change_count (int): 当前对手刷新次数,每天最多刷新 5 次。 + remain (int): 剩余演习次数。 + preserve (int): 保留次数,剩余演习次数低于此值时停止。 + """ + opponent_change_count = 0 remain = 0 preserve = 0 def _new_opponent(self): - logger.info('New opponent') + """ + 刷新对手列表。 + + 点击刷新按钮获取新的对手,并记录当天的刷新次数。 + """ + logger.info('[演习-对手] 刷新对手') self.appear_then_click(NEW_OPPONENT) self.opponent_change_count += 1 - logger.attr("Change_opponent_count", self.opponent_change_count) + logger.attr('对手刷新次数', self.opponent_change_count) self.config.set_record(Exercise_OpponentRefreshValue=self.opponent_change_count) self.ensure_no_info_bar(timeout=3) def _opponent_fleet_check_all(self): + """ + 检查所有对手的舰队信息。 + + 当选择模式为 leftmost 时跳过检查,直接使用最左侧对手。 + """ if self.config.Exercise_OpponentChooseMode != 'leftmost': super()._opponent_fleet_check_all() def _opponent_sort(self, method=None): + """ + 根据策略对对手进行排序。 + + Args: + method (str): 排序方法,默认使用配置中的 Exercise_OpponentChooseMode。 + leftmost 模式直接返回 [0, 1, 2, 3]。 + + Returns: + list[int]: 对手索引列表,按优先级排序。 + """ if method is None: method = self.config.Exercise_OpponentChooseMode if method != 'leftmost': @@ -115,7 +180,7 @@ def _exercise_once(self): self._opponent_fleet_check_all() while 1: for opponent in self._opponent_sort(): - logger.hr(f'Opponent {opponent}', level=2) + logger.hr(f'对手 {opponent}', level=2) success = self._combat(opponent) if success: return success @@ -141,7 +206,7 @@ def _exercise_easiest_else_exp(self): self._opponent_fleet_check_all() while 1: opponents = self._opponent_sort(method=method) - logger.hr(f'Opponent {opponents[0]}', level=2) + logger.hr(f'对手 {opponents[0]}', level=2) self.config.override(Exercise_LowHpThreshold=threshold) success = self._combat(opponents[0]) if success: @@ -149,12 +214,12 @@ def _exercise_easiest_else_exp(self): return success else: if self.opponent_change_count < 5: - logger.info("Cannot beat calculated easiest opponent, refresh") + logger.info("[演习-对手] 无法击败最简单对手,刷新") self._new_opponent() self._opponent_fleet_check_all() continue else: - logger.info("Cannot beat calculated easiest opponent, MAX EXP then") + logger.info("[演习-对手] 无法击败最简单对手,切换最大经验") method = "max_exp" threshold = 0 @@ -190,11 +255,15 @@ def _get_exercise_reset_remain(self): def _get_exercise_strategy(self): """ - 获取演习策略。 + 获取演习消耗策略。 + + 根据配置的 Exercise_ExerciseStrategy 确定保留次数和将军试炼时间区间。 Returns: - int: 保留次数,即剩余演习次数阈值。 - list, int: 将军试炼时间区间。 + tuple: (preserve, admiral_interval) + - preserve (int): 保留次数,激进模式为 0,保守模式为 5。 + - admiral_interval (list 或 None): 将军试炼时间区间 [start, end](小时), + 激进模式为 None。 """ if self.config.Exercise_ExerciseStrategy == "aggressive": preserve = 0 @@ -206,30 +275,45 @@ def _get_exercise_strategy(self): return preserve, admiral_interval def run(self): + """ + 演习任务主入口。 + + 流程: + 1. 导航到演习页面 + 2. 获取对手刷新次数和消耗策略 + 3. 检查是否达到将军试炼时间区间,决定是否强制消耗 + 4. 检查是否需要延迟执行 + 5. 循环执行演习直到次数用尽或达到保留阈值 + 6. 设置下次任务调度时间 + + Pages: + in: 任意页面 + out: page_exercise + """ self.ui_ensure(page_exercise) server_update = self.config.Scheduler_ServerUpdate self.opponent_change_count = self._get_opponent_change_count() - logger.attr("Change_opponent_count", self.opponent_change_count) - logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) + logger.attr('对手刷新次数', self.opponent_change_count) + logger.attr('演习消耗策略', self.config.Exercise_ExerciseStrategy) self.preserve, admiral_interval = self._get_exercise_strategy() remain_time = OCR_PERIOD_REMAIN.ocr(self.device.image) - logger.info(f'Exercise period remain: {remain_time}') + logger.info(f'[演习-调度] 演习赛季剩余时间: {remain_time}') if admiral_interval is not None and remain_time: admiral_start, admiral_end = admiral_interval if admiral_start > int(remain_time.total_seconds() // 3600) >= admiral_end: # 达到将军试炼设定时间 - logger.info('Reach set time for admiral trial, using all attempts.') + logger.info('[演习-调度] 达到将军试炼设定时间,消耗所有次数') self.preserve = 0 forced_run =True elif int(remain_time.total_seconds() // 3600) < 6: # 未设置为 "sun18" 时,仍在周日 18 点前消耗 - logger.info('Exercise period remain less than 6 hours, using all attempts.') + logger.info('[演习-调度] 演习赛季剩余不足6小时,消耗所有次数') self.preserve = 0 forced_run = True else: - logger.info(f'Preserve {self.preserve} exercise') + logger.info(f'[演习-调度] 保留 {self.preserve} 次演习') forced_run = False else: forced_run = False @@ -238,8 +322,8 @@ def run(self): if ((get_server_next_update(server_update) - current_time()).seconds > 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate)\ and not forced_run: - logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' - f'hours before next update. Delay task to it.') + logger.warning(f'[演习-调度] 应在下次更新前 {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' + f'小时执行,延迟任务') run = False else: run = True @@ -249,13 +333,13 @@ def run(self): if self.remain <= self.preserve: break - logger.hr(f'Exercise remain {self.remain}', level=1) + logger.hr(f'演习剩余 {self.remain}', level=1) if self.config.Exercise_OpponentChooseMode == "easiest_else_exp": success = self._exercise_easiest_else_exp() else: success = self._exercise_once() if not success: - logger.info('New opponent exhausted') + logger.info('[演习-对手] 对手刷新次数耗尽') break # self.equipment_take_off_when_finished() diff --git a/module/exercise/hp_daemon.py b/module/exercise/hp_daemon.py index 22f3f8e7e..df3bf5ee6 100644 --- a/module/exercise/hp_daemon.py +++ b/module/exercise/hp_daemon.py @@ -1,3 +1,20 @@ +"""演习血量实时监控模块。 + +在演习(PvP)战斗中实时监控攻守双方的 HP 百分比。 +通过图像识别分析 HP 条的颜色分布,计算当前血量。 + +HP 条颜色分析: +- 绿色区域:血量充足 +- 红色区域:血量较低 +- 通过颜色占比计算 HP 百分比(0.0 到 1.0) + +使用场景: +- 当己方血量过低时触发保护逻辑(如撤退) +- 记录战斗过程中的血量变化 + +继承自 ModuleBase,被 Exercise 组合使用。 +""" + # 此文件实现了演习(Exercise)模式下的血量实时监控逻辑。 # 它通过图像识别计算攻守双方的 HP 百分比,并在己方血量过低时触发相应逻辑以保护单局胜率或撤退。 from module.base.base import ModuleBase @@ -9,6 +26,15 @@ class HpDaemon(ModuleBase): + """演习血量监控守护器。 + + 实时监控演习战斗中攻守双方的 HP 状态。 + + Attributes: + attacker_hp (float): 攻击方(己方)的 HP 百分比 (0.0-1.0)。 + defender_hp (float): 防守方(敌方)的 HP 百分比 (0.0-1.0)。 + low_hp_confirm_timer (Timer): 低血量确认计时器,防止误判。 + """ attacker_hp = 1.0 defender_hp = 1.0 # _last_secure_time = 0 diff --git a/module/exercise/opponent.py b/module/exercise/opponent.py index 22379def5..57d8af531 100644 --- a/module/exercise/opponent.py +++ b/module/exercise/opponent.py @@ -1,3 +1,18 @@ +""" +演习对手分析与选择模块。 + +通过 OCR 识别对手的等级和战力信息,并根据配置的策略对对手进行排序。 + +支持的对手选择策略: +- max_exp: 按等级总和排序,优先攻击经验最高的对手 +- easiest: 按综合难度排序,优先攻击最容易击败的对手 + (等级越低、战力越低的对手优先级越高) +- leftmost: 按默认顺序(最左优先) + +常量说明: + MAX_LVL_SUM: 最大等级总和(6 艘船 * 125 级 = 750) + PWR_FACTOR: 战力归一化因子,用于将战力缩放到可比较范围 +""" import numpy as np from module.base.button import ButtonGrid @@ -18,6 +33,13 @@ class Level(Digit): + """ + 演习对手等级 OCR 识别器。 + + 对数字 OCR 进行预处理,去除左侧多余空白并添加白色边距, + 以提高演习准备界面中等级数字的识别准确率。 + """ + def pre_process(self, image): image = super().pre_process(image) image = image_left_strip(image, threshold=85, length=22) @@ -27,6 +49,18 @@ def pre_process(self, image): class Opponent: + """ + 演习对手数据类,包含对手的等级和战力信息。 + + 从演习页面截图中 OCR 识别对手的两支舰队的等级和战力, + 并根据指定策略计算对手的优先级分数。 + + Attributes: + index (int): 对手索引(0-3,从左到右)。 + power (list[int]): 两支舰队的战力,如 [14848, 13477]。 + level (list[int]): 六艘舰船的等级,如 [120, 120, 120, 120, 120, 120]。 + """ + def __init__(self, main_image, fleet_image, index): self.index = index self.power = self.get_power(image=main_image) @@ -36,7 +70,7 @@ def __init__(self, main_image, fleet_image, index): level = [str(x).rjust(3, ' ') for x in self.level] power = ['(' + str(x).rjust(5, ' ') + ')' for x in self.power] logger.attr( - 'OPPONENT_%s' % index, + '对手_%s' % index, ' '.join([power[0]] + level[:3] + ['|'] + [power[1]] + level[3:]) ) @@ -92,10 +126,26 @@ def get_priority(self, method="max_exp"): class OpponentChoose(UI): + """ + 对手选择器,负责检查和排序演习对手。 + + 依次进入每个对手的准备界面,OCR 识别其舰队等级和战力, + 然后根据策略对手进行排序,返回攻击优先级列表。 + + Attributes: + main_image (numpy.ndarray): 演习主页面截图,用于识别战力。 + opponents (list[Opponent]): 4 个对手的 Opponent 数据对象列表。 + """ + main_image = None opponents = [] def _opponent_fleet_check_all(self): + """ + 依次检查所有 4 个对手的舰队信息。 + + 通过点击每个对手进入演习准备界面,OCR 识别等级和战力后返回。 + """ self.opponents = [] self.main_image = self.device.image @@ -118,5 +168,5 @@ def _opponent_sort(self, method="max_exp"): Attack one by one. """ order = np.argsort([- x.get_priority(method) for x in self.opponents]) - logger.attr('Order', str(order)) + logger.attr('出战顺序', str(order)) return order diff --git a/module/freebies/assets.py b/module/freebies/assets.py index b8c92d869..31bf3fe95 100644 --- a/module/freebies/assets.py +++ b/module/freebies/assets.py @@ -1,3 +1,5 @@ +"""免费福利模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/freebies/battle_pass.py b/module/freebies/battle_pass.py index 97648115e..c22beecf3 100644 --- a/module/freebies/battle_pass.py +++ b/module/freebies/battle_pass.py @@ -1,3 +1,7 @@ +"""战斗通行证处理器,检测红点并领取战斗通行证奖励。 +通过颜色检测判断是否有可领取奖励,自动完成领取流程。 +""" + from module.base.timer import Timer from module.base.utils import get_color from module.combat.combat import Combat @@ -26,13 +30,13 @@ def battle_pass_red_dot_appear(self): # 此处不使用 self.appear(),因为红点是透明的,颜色会随背景变化。 r, _, _ = get_color(self.device.image, BATTLE_PASS_RED_DOT.button) if r > BATTLE_PASS_RED_DOT.color[0] - 40: - logger.info('Found battle pass red dot') + logger.info('[免费福利-通行证] 发现战斗通行证红点') return True else: - logger.info('No battle pass red dot') + logger.info('[免费福利-通行证] 没有战斗通行证红点') return False else: - logger.warning('No battle pass entrance') + logger.warning('[免费福利-通行证] 没有战斗通行证入口') return False def handle_battle_pass_popup(self): @@ -67,7 +71,7 @@ def battle_pass_receive(self, skip_first_screenshot=True): in: page_battle_pass out: page_battle_pass """ - logger.hr('Battle pass receive', level=1) + logger.hr('领取战斗通行证奖励', level=1) self.battle_status_click_interval = 2 confirm_timer = Timer(1, count=3).start() received = False @@ -120,7 +124,7 @@ def battle_pass_receive(self, skip_first_screenshot=True): else: confirm_timer.reset() - logger.info(f'Battle pass receive finished, received={received}') + logger.info(f'[免费福利-通行证] 战斗通行证奖励领取完成, received={received}') return received def run(self): diff --git a/module/freebies/data_key.py b/module/freebies/data_key.py index 1bb637aeb..6da8ab58c 100644 --- a/module/freebies/data_key.py +++ b/module/freebies/data_key.py @@ -1,3 +1,7 @@ +"""数据密钥收集器,自动收取作战档案中的数据密钥。 +通过 OCR 检测密钥数量,判断是否需要收集。 +""" + from module.combat.assets import GET_ITEMS_1 from module.freebies.assets import * from module.logger import logger @@ -17,7 +21,7 @@ def _data_key_collect(self, skip_first_screenshot=True): in: page_archives out: page_archives, DATA_KEY_COLLECTED """ - logger.hr('Data Key Collect') + logger.hr('数据钥匙收集') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -39,27 +43,27 @@ def _data_key_collect(self, skip_first_screenshot=True): # End if self.appear(WAR_ARCHIVES_CHECK, offset=(20, 20)) and self.appear(DATA_KEY_COLLECTED, offset=(20, 20)): - logger.info('Data key collect finished') + logger.info('[免费福利-钥匙] 数据钥匙收集完成') break def data_key_collect(self): """ - Execute data key collection + 执行数据钥匙收集。 Returns: - bool: If execute a collection. + bool: 是否执行了收集。 Pages: in: page_archives """ if self.appear(DATA_KEY_COLLECTED, offset=(20, 20)): - logger.info('Data key has been collected') + logger.info('[免费福利-钥匙] 数据钥匙已收集') return False current, remain, total = DATA_KEY.ocr(self.device.image) - logger.info(f'Inventory: {current} / {total}, Remain: {remain}') + logger.info(f'[免费福利-钥匙] 背包: {current} / {total}, 剩余: {remain}') if not self.config.DataKey_ForceCollect and remain <= 0: - logger.info('No more room for additional data key') + logger.info('[免费福利-钥匙] 没有更多空间存放数据钥匙') return False self._data_key_collect() diff --git a/module/freebies/freebies.py b/module/freebies/freebies.py index f22221508..beb5c63a1 100644 --- a/module/freebies/freebies.py +++ b/module/freebies/freebies.py @@ -1,3 +1,14 @@ +""" +免费福利(Freebies)任务调度模块。 + +负责统一调度所有免费福利相关的子模块,按顺序执行: +- 战斗通行证(Battle Pass)奖励收集 +- 数据钥匙(Data Key)收集 +- 邮件(Mail)奖励领取 +- 补给包(Supply Pack)收集 + +各子模块根据用户配置决定是否执行,全部完成后设置下次运行时间。 +""" from module.base.base import ModuleBase from module.freebies.battle_pass import BattlePass from module.freebies.data_key import DataKey @@ -7,23 +18,31 @@ class Freebies(ModuleBase): + """ + 免费福利任务总调度器。 + + 按优先级依次执行战斗通行证、数据钥匙、邮件和补给包的收集。 + 每个子模块独立检查用户配置中的开关,决定是否执行。 + 全部子模块执行完毕后,通过 `task_delay(server_update=True)` 设置 + 下次运行时间为服务器刷新时间。 + """ def run(self): """ 运行所有免费福利相关模块。 """ if self.config.BattlePass_Collect: - logger.hr('Battle pass', level=1) + logger.hr('战斗通行证', level=1) BattlePass(self.config, self.device).run() if self.config.DataKey_Collect: - logger.hr('Data key', level=1) + logger.hr('数据钥匙', level=1) DataKey(self.config, self.device).run() - logger.hr('Mail', level=1) + logger.hr('邮件', level=1) MailWhite(self.config, self.device).run() if self.config.SupplyPack_Collect: - logger.hr('Supply pack', level=1) + logger.hr('补给包', level=1) SupplyPack_250814(self.config, self.device).run() self.config.task_delay(server_update=True) diff --git a/module/freebies/mail_white.py b/module/freebies/mail_white.py index b950fa58c..cbf32ab3f 100644 --- a/module/freebies/mail_white.py +++ b/module/freebies/mail_white.py @@ -1,3 +1,15 @@ +""" +邮件白色主题 UI 处理模块。 + +处理碧蓝航线邮件页面的完整交互流程,包括: +- 邮件页面的进入与退出 +- 按类型筛选并批量领取邮件奖励(功勋、维护补偿、贸易许可证) +- 批量删除已领取的邮件 +- 处理白色主题 UI 下的邮件相关弹窗和确认框 + +该模块专为白色主题 UI 设计,通过 MailSelectSetting 配置 +邮件内容筛选条件(魔方、金币、石油、功勋、钻石等)。 +""" from module.base.decorator import cached_property from module.base.timer import Timer from module.combat.assets import GET_ITEMS_1, GET_ITEMS_2 @@ -9,11 +21,34 @@ class MailSelectSetting(Setting): + """ + 邮件筛选设置管理器。 + + 继承自 Setting,用于管理邮件内容类型的筛选选项。 + 通过检测选项按钮的颜色(深灰色 (57, 56, 57))判断选项是否激活。 + """ + def is_option_active(self, option: Button) -> bool: return self.main.image_color_count(option, color=(57, 56, 57), threshold=221, count=50) class MailWhite(UI): + """ + 白色主题邮件处理器。 + + 负责白色主题 UI 下邮件的领取和清理操作。支持以下功能: + - 按内容类型筛选邮件(功勋、维护补偿、贸易许可证) + - 批量领取符合条件的邮件奖励 + - 批量删除已领取的邮件 + + 使用 MailSelectSetting 管理筛选条件,包含两个设置实例: + - mail_select_setting: 按类型筛选(魔方、金币、石油、功勋、钻石) + - mail_select_all_setting: 全选模式,用于批量删除 + + Attributes: + mail_select_setting: 按内容类型筛选的设置实例(cached_property)。 + mail_select_all_setting: 全选模式的设置实例(cached_property)。 + """ @cached_property def mail_select_setting(self): setting = MailSelectSetting('Mail', main=self) @@ -50,7 +85,7 @@ def _mail_enter(self, skip_first_screenshot=True): in: page_main_white 或 MAIL_MANAGE out: MAIL_BATCH_CLAIM """ - logger.info('Mail enter') + logger.info('进入邮件') self.interval_clear([ MAIL_MANAGE ]) @@ -64,15 +99,15 @@ def _mail_enter(self, skip_first_screenshot=True): # End if self.appear(MAIL_BATCH_CLAIM, offset=(20, 20)): - logger.info('Mail entered') + logger.info('进入邮件ed') return True if self.appear(MAIL_WHITE_EMPTY, offset=(20, 20)): - logger.info('Mail empty') + logger.info('邮件为空') return False if not has_mail and self.appear(GOTO_MAIN_WHITE, offset=(20, 20)): timeout.start() if timeout.reached(): - logger.info('Mail empty, wait GOTO_MAIN_WHITE timeout') + logger.info('邮件为空, wait GOTO_MAIN_WHITE timeout') return False # Click @@ -92,7 +127,7 @@ def _mail_quit(self, skip_first_screenshot=True): in: page_mail 中的任意页面 out: page_main_white """ - logger.info('Mail quit') + logger.info('退出邮件') self.interval_clear([ MAIL_BATCH_CLAIM, GOTO_MAIN_WHITE, @@ -108,7 +143,7 @@ def _mail_quit(self, skip_first_screenshot=True): # End if self.is_in_main(): - logger.info('Mail quit to main') + logger.info('退出邮件 to main') break # Click @@ -124,6 +159,15 @@ def _mail_quit(self, skip_first_screenshot=True): continue def _handle_mail_reward(self): + """ + 处理邮件奖励领取后的物品获取弹窗。 + + 检测 GET_ITEMS_1 或 GET_ITEMS_2 弹窗出现时,自动点击确认 + 以完成奖励领取流程。 + + Returns: + bool: 是否检测到并处理了物品获取弹窗。 + """ if self.appear(GET_ITEMS_1, offset=(30, 30), interval=3): logger.info(f'{GET_ITEMS_1} -> {MAIL_BATCH_CLAIM}') self.device.click(MAIL_BATCH_CLAIM) @@ -174,7 +218,7 @@ def _mail_claim_execute(self, skip_first_screenshot=True): continue success = self.info_bar_count() > 0 - logger.info(f'Mail claim success: {success}') + logger.info(f'邮件领取成功: {success}') return success def _mail_delete(self, skip_first_screenshot=True): @@ -237,12 +281,12 @@ def mail_claim( return if merit: - logger.hr('Mail merit', level=2) + logger.hr('邮件功勋', level=2) self._mail_enter() self.mail_select_setting.set(contains=['merit']) self._mail_claim_execute() if maintenance: - logger.hr('Mail maintenance', level=2) + logger.hr('邮件维护', level=2) self._mail_enter() self.mail_select_setting.set(contains=['coins', 'oil']) self._mail_claim_execute() @@ -250,12 +294,12 @@ def mail_claim( self.mail_select_setting.set(contains=['coins', 'oil', 'gems']) self._mail_claim_execute() if trade_license: - logger.hr('Mail trade license', level=2) + logger.hr('邮件贸易许可', level=2) self._mail_enter() self.mail_select_setting.set(contains=['coins', 'oil', 'cube']) self._mail_claim_execute() if delete: - logger.hr('Mail delete', level=2) + logger.hr('邮件删除', level=2) self._mail_enter() self.mail_select_all_setting.set(contains=['all']) self._mail_delete() @@ -267,22 +311,22 @@ def run(self): maintenance = self.config.Mail_ClaimMaintenance trade_license = self.config.Mail_ClaimTradeLicense delete = self.config.Mail_DeleteCollected - logger.info(f'Mail reward: merit={merit}, maintenance={maintenance}, ' - f'trade_license={trade_license}, delete={delete}') + logger.info(f'[免费福利-邮件] 邮件奖励: 功勋={merit}, 维护补偿={maintenance}, ' + f'贸易许可={trade_license}, 删除={delete}') if not merit and not maintenance and not trade_license: - logger.warning('Nothing to claim') + logger.warning('无内容可领取') return False # 必须使用白色主题 UI self.ui_ensure(page_main) if self.appear(page_main_white.check_button, offset=(30, 30)): - logger.info('At page_main_white') + logger.info('在白色主页') pass elif self.appear(page_main.check_button, offset=(5, 5)): - logger.info('At page_main') + logger.info('在主页') pass else: - logger.warning('Unknown page_main, cannot enter mail page') + logger.warning('[免费福利-邮件] 未知的主页面,无法进入邮件页面') return False # 领取 diff --git a/module/freebies/supply_pack.py b/module/freebies/supply_pack.py index 66c082ebb..df053b6c4 100644 --- a/module/freebies/supply_pack.py +++ b/module/freebies/supply_pack.py @@ -1,3 +1,7 @@ +"""补给包处理器,自动购买每周和每日补给包。 +根据星期和服务器时间判断可购买的补给包类型。 +""" + from calendar import day_name from module.base.timer import Timer @@ -21,10 +25,10 @@ def supply_pack_buy(self, supply_pack, skip_first_screenshot=True): Returns: bool: If bought. """ - logger.hr('Supply pack buy') + logger.hr('购买补给包') [self.interval_clear(asset) for asset in [GET_ITEMS_1, GET_ITEMS_2, supply_pack, BUY_CONFIRM]] - logger.info(f'Buying {supply_pack}') + logger.info(f'[免费福利-补给] 购买 {supply_pack}') executed = False click_count = 0 confirm_timer = Timer(1, count=3).start() @@ -36,7 +40,7 @@ def supply_pack_buy(self, supply_pack, skip_first_screenshot=True): if self.appear(supply_pack, offset=(200, 20), interval=3): if click_count >= 3: - logger.warning(f'Failed to buy {supply_pack} after 3 trail, probably reached resource limit, skip') + logger.warning(f'[免费福利-补给] 购买 {supply_pack} 尝试3次后失败,可能达到资源限制,跳过') break self.device.click(supply_pack) click_count += 1 @@ -63,7 +67,7 @@ def supply_pack_buy(self, supply_pack, skip_first_screenshot=True): else: confirm_timer.reset() - logger.info(f'Supply pack buy finished, executed={executed}') + logger.info(f'购买补给包 finished, executed={executed}') return executed def goto_supply_pack(self, skip_first_screenshot=True): @@ -89,9 +93,9 @@ def run(self): if server_today >= target: self.supply_pack_buy(FREE_SUPPLY_PACK) else: - logger.info(f'Delaying free week supply pack to {target_name}') + logger.info(f'[免费福利-补给] 将免费周补给包延迟到 {target_name}') else: - logger.info('Oil > 21000, unable to buy free weekly supply pack') + logger.info('石油超限,无法购买免费周补给包') class SupplyPack_250814(SupplyPack): @@ -109,11 +113,11 @@ def get_oil(self, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning('Get oil timeout') + logger.warning('获取石油超时') break if not self.appear(SHOP_OCR_OIL_CHECK, offset=(10, 2)): - logger.info('No oil icon') + logger.info('无石油图标') continue ocr = Digit(SHOP_OCR_OIL, name='OCR_OIL', letter=(247, 247, 247), threshold=128) amount = ocr.ocr(self.device.image) @@ -128,11 +132,11 @@ def goto_supply_pack(self, skip_first_screenshot=True): in: page_shop out: page_supply_pack, supply pack tab """ - logger.info('Goto supply pack') + logger.info('前往补给包') for _ in self.loop(): if self.match_template_color(page_supply_pack.check_button, offset=(20, 20)): - logger.info('At supply pack') + logger.info('在补给包') break elif self.appear_then_click(page_supply_pack.check_button, offset=(20, 20), interval=3): diff --git a/module/gacha/assets.py b/module/gacha/assets.py index efb61b91d..57c9a3fb6 100644 --- a/module/gacha/assets.py +++ b/module/gacha/assets.py @@ -1,3 +1,5 @@ +"""建造模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/gacha/gacha_reward.py b/module/gacha/gacha_reward.py index 2f2269929..160a32747 100644 --- a/module/gacha/gacha_reward.py +++ b/module/gacha/gacha_reward.py @@ -1,3 +1,7 @@ +"""建造(Gacha)系统模块,处理舰船建造的完整流程。 +包括建造页面导航、资源消耗预计算、订单提交、 +自动收菜(获取建造结果)以及建造队列的清理管理。""" + # 此文件处理建造(Gacha/Build)相关的操作。 # 包括多级建造页面的导航、资源消耗预计算、提交建造订单以及自动化收菜和队列清理逻辑。 from module.base.timer import Timer @@ -84,8 +88,8 @@ def gacha_prep(self, target, skip_first_screenshot=True): # 检查是否异常提前退出,并设置正确的提交数量 if ocr_submit is None: - raise ScriptError('Failed to identify ocr asset required, ' - 'cannot continue prep work') + raise ScriptError('[建造-准备] 无法识别OCR资产,' + '无法继续准备工作') area = ocr_submit.buttons[0] ocr_submit.buttons = [(BUILD_MINUS.button[2] + 3, area[1], BUILD_PLUS.button[0] - 3, area[3])] self.ui_ensure_index(target, letter=ocr_submit, prev_button=BUILD_MINUS, @@ -112,7 +116,7 @@ def gacha_calculate(self, target_count, gold_cost, cube_cost): # 数量为 0,无法执行建造 if not target_count: - logger.warning('Insufficient gold and/or cubes to gacha roll') + logger.warning('物资和/或心智魔方不足,无法建造') break # 资源不足,减少 1 并重新计算 @@ -123,7 +127,7 @@ def gacha_calculate(self, target_count, gold_cost, cube_cost): break # 扣除资源,返回当前 target_count - logger.info(f'Able to submit up to {target_count} build orders') + logger.info(f'最多可提交 {target_count} 个建造订单') self.build_coin_count -= gold_total self.build_cube_count -= cube_total LogRes(self.config).Cube = self.build_cube_count @@ -252,7 +256,7 @@ def gacha_flush_queue(self, skip_first_screenshot=True): # 许愿池不再显示金币,返回普通建造池 if self.appear(BUILD_SUBMIT_WW_ORDERS): - logger.info('In wishing pool, go back to normal pools') + logger.info('在许愿池中,返回普通池') self.gacha_side_navbar_ensure(upper=1) def gacha_submit(self, skip_first_screenshot=True): @@ -266,7 +270,7 @@ def gacha_submit(self, skip_first_screenshot=True): in: POPUP_CONFIRM out: BUILD_FINISH_ORDERS """ - logger.info('Submit gacha') + logger.info('提交建造') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -323,7 +327,7 @@ def gacha_run(self): if self.appear(BUILD_TICKET_CHECK, offset=(30, 30)): self.build_ticket_count = OCR_BUILD_TICKET_COUNT.ocr(self.device.image) else: - logger.info('Build ticket not detected, use cubes and coins') + logger.info('未检测到建造券,使用魔方和物资') if self.config.Gacha_Amount > self.build_ticket_count: buy[0] = self.build_ticket_count # 根据配置和资源计算允许的建造次数 diff --git a/module/gacha/ui.py b/module/gacha/ui.py index af4c51067..3aa11b3e6 100644 --- a/module/gacha/ui.py +++ b/module/gacha/ui.py @@ -1,3 +1,10 @@ +"""建造系统 UI 处理模块。 + +提供建造(Gacha)页面的 UI 操作,包括页面资源加载检测、 +侧边栏标签导航(轻型/重型/特型/限时建造)、 +建造池切换以及建造结果处理等界面交互功能。 +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.base.timer import Timer @@ -39,7 +46,7 @@ def gacha_load_ensure(self, skip_first_screenshot=True): # 超时异常——资源加载未完成 if ensure_timeout.reached(): - logger.warning('Wait for loaded assets is incomplete, ensure not guaranteed') + logger.warning('[建造-UI] 等待资源加载超时,加载未完成') return False @cached_property @@ -91,7 +98,7 @@ def gacha_side_navbar_ensure(self, upper=None, bottom=None): """ retire_upper = 5 if self._gacha_side_navbar.get_total(main=self) == 5 else 4 if upper == retire_upper or bottom == 1: - logger.warning('Transitions to "retire" is not supported') + logger.warning('[建造-UI] 不支持跳转到退役页面') return False if self._gacha_side_navbar.set(self, upper=upper, bottom=bottom) \ @@ -194,7 +201,7 @@ def gacha_bottom_navbar_ensure(self, left=None, right=None, is_build=True): gacha_bottom_navbar = self._gacha_bottom_navbar(is_build) if is_build and gacha_bottom_navbar.get_total(main=self) == 3: if left == 1 or right == 4: - logger.info('Construct event not available, default to light') + logger.info('[建造-UI] 限时建造不可用,切换到轻型建造') left = 1 right = None if left == 4: diff --git a/module/game_setting/setting_extractor.py b/module/game_setting/setting_extractor.py index 0eb3f1053..5ed7214d3 100644 --- a/module/game_setting/setting_extractor.py +++ b/module/game_setting/setting_extractor.py @@ -1,3 +1,7 @@ +"""游戏设置提取器,从碧蓝航线 Lua 源代码中解析 PlayerPrefs 配置项。 +通过正则表达式匹配 GetInt/GetFloat/GetString 等调用, +自动提取键名、类型和默认值。""" + import os import re from dataclasses import dataclass diff --git a/module/game_setting/setting_generated.py b/module/game_setting/setting_generated.py index 3e67815ff..59a47e174 100644 --- a/module/game_setting/setting_generated.py +++ b/module/game_setting/setting_generated.py @@ -1,3 +1,7 @@ +"""游戏设置自动生成文件,由 setting_extractor 生成。 +定义了 GameSettingsGenerated 类,包含游戏中所有 PlayerPrefs +配置项的字段声明(类型、默认值、匹配正则)。""" + from module.game_setting.setting_extractor import Field # 由 module/game_setting/setting_extractor.py 自动生成 diff --git a/module/guild/assets.py b/module/guild/assets.py index 9eef24a2f..1e0fda01c 100644 --- a/module/guild/assets.py +++ b/module/guild/assets.py @@ -1,3 +1,5 @@ +"""大舰队模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/guild/base.py b/module/guild/base.py index a058c2c14..66b0d60d0 100644 --- a/module/guild/base.py +++ b/module/guild/base.py @@ -1,3 +1,13 @@ +"""大舰队基础模块。 + +提供大舰队系统的通用 UI 操作,包括侧边栏导航。 +作为 `GuildLogistics` 等具体功能模块的基类,负责大舰队页面间的导航切换。 + +主要功能: +- 侧边栏导航栏的初始化与状态管理 +- 导航到大舰队指定子页面(大厅、成员、后勤、科技、作战等) +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.logger import logger @@ -6,6 +16,15 @@ class GuildBase(UI): + """大舰队基础操作类。 + + 提供大舰队系统中侧边栏导航的通用功能。 + 根据用户身份(司令/普通成员),侧边栏包含不同数量的选项。 + + Attributes: + _guild_side_navbar (Navbar): 大舰队侧边导航栏实例, + 支持 6 个选项(司令)或 5 个选项(普通成员)。 + """ @cached_property def _guild_side_navbar(self): """ @@ -60,7 +79,7 @@ def guild_side_navbar_ensure(self, upper=None, bottom=None): """ if self._guild_side_navbar.get_total(main=self) == 6: if upper == 3 or bottom == 4: - logger.warning('Transitions to "apply" is not supported') + logger.warning('[大舰队-导航] 不支持切换到"申请"页面') return False if self._guild_side_navbar.set(self, upper=upper, bottom=bottom): diff --git a/module/guild/guild_combat.py b/module/guild/guild_combat.py index c05661d11..e87113bc9 100644 --- a/module/guild/guild_combat.py +++ b/module/guild/guild_combat.py @@ -1,3 +1,7 @@ +"""大舰队战斗处理器,继承战斗基类并适配大舰队特有的战斗结算画面。 +处理大世界风格的战斗状态和获取物品界面。 +""" + from module.combat.combat import Combat from module.guild.assets import BATTLE_STATUS_CF, EXP_INFO_CF diff --git a/module/guild/guild_reward.py b/module/guild/guild_reward.py index 92d3f8ded..f84106d0b 100644 --- a/module/guild/guild_reward.py +++ b/module/guild/guild_reward.py @@ -1,3 +1,7 @@ +"""大舰队奖励任务入口,统一调度大厅、后勤和作战三个子任务。 +从主页面进入大舰队,依次执行各子任务后返回。 +""" + from module.guild.lobby import GuildLobby from module.guild.logistics import GuildLogistics from module.guild.operations import GuildOperations diff --git a/module/guild/lobby.py b/module/guild/lobby.py index 332a58799..05cccd20c 100644 --- a/module/guild/lobby.py +++ b/module/guild/lobby.py @@ -1,3 +1,7 @@ +"""大舰队大厅处理器,负责大厅中的报告领取、签到和成员信息检测。 +通过颜色识别红点和按钮状态来判断可执行操作。 +""" + import numpy as np from module.base.button import Button @@ -92,6 +96,6 @@ def guild_lobby(self): in: GUILD_LOBBY out: GUILD_LOBBY """ - logger.hr('Guild lobby', level=1) + logger.hr('大舰队大厅', level=1) self._guild_lobby_collect() - logger.info('Guild lobby collect finished') + logger.info('[大舰队-大厅] 大舰队大厅收集完成') diff --git a/module/guild/logistics.py b/module/guild/logistics.py index 62bba8109..aa80f199b 100644 --- a/module/guild/logistics.py +++ b/module/guild/logistics.py @@ -1,3 +1,16 @@ +"""大舰队后勤模块。 + +处理大舰队后勤页面中的所有交互操作,包括: +- 大舰队物资补给的领取 +- 每周大舰队任务的接取与奖励领取 +- 大舰队资源兑换(使用舰队资源兑换物品) + +该模块支持多服务器(CN/EN/JP/TW)的差异化处理, +通过 `@Config.when(SERVER=...)` 装饰器实现服务器特定的任务状态检测逻辑。 + +配置项前缀:`GuildLogistics_*` +""" + import re from module.base.button import ButtonGrid @@ -23,6 +36,10 @@ class ExchangeLimitOcr(Digit): + """大舰队兑换次数 OCR 识别器。 + + 对图像进行反色和灰度映射预处理,以提高兑换次数数字的识别精度。 + """ def pre_process(self, image): """ Args: @@ -38,10 +55,31 @@ def pre_process(self, image): class GuildLogistics(GuildBase): + """大舰队后勤处理器。 + + 负责大舰队后勤页面中的所有自动化操作: + - 领取物资补给(supply) + - 接取或领取每周任务奖励(mission) + - 使用资源兑换物品(exchange) + + 通过状态循环模式管理多个子任务的执行顺序, + 并处理各种弹窗和异常情况。 + + Attributes: + _guild_logistics_mission_finished (bool): 本周大舰队任务是否已完成。 + exchange_items (ItemGrid): 兑换物品网格,用于识别可兑换的物品类型。 + """ _guild_logistics_mission_finished = False @cached_property def exchange_items(self): + """获取兑换物品网格实例。 + + 加载 `./assets/stats_basic` 中的模板图像,用于识别可兑换的物品类型。 + + Returns: + ItemGrid: 兑换物品网格对象。 + """ item_grid = ItemGrid( EXCHANGE_GRIDS, {}, template_area=(40, 21, 89, 70), amount_area=(60, 71, 91, 92)) item_grid.load_template_folder('./assets/stats_basic') @@ -109,21 +147,21 @@ def _guild_logistics_mission_available(self): r, g, b = get_color(self.device.image, GUILD_MISSION.area) if g > max(r, b) - 10: # Green tick at the bottom right corner if guild mission finished - logger.info('Guild mission has finished this week') + logger.info('[大舰队-后勤] 本周大舰队任务已完成') self._guild_logistics_mission_finished = True return False # 0/300 in EN is bold and pure white, and Collect rewards is blue white, so reverse the if condition elif self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=235, count=100): - logger.info('Guild mission button inactive') + logger.info('[大舰队-后勤] 大舰队任务按钮未激活') return False elif self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=180, count=50): # white pixels less than 50, but has blue-white pixels - logger.info('Guild mission button active') + logger.info('[大舰队-后勤] 大舰队任务按钮已激活') return True else: # No guild mission counter - logger.info('No guild mission found, mission of this week may not started') + logger.info('[大舰队-后勤] 未找到大舰队任务,本周任务可能未开始') return False # if self.image_color_count(GUILD_MISSION_CHOOSE, color=(255, 255, 255), threshold=221, count=100): # # Guild mission choose available if user is guild master @@ -152,20 +190,20 @@ def _guild_logistics_mission_available(self): r, g, b = get_color(self.device.image, GUILD_MISSION.area) if g > max(r, b) - 10: # Green tick at the bottom right corner if guild mission finished - logger.info('Guild mission has finished this week') + logger.info('[大舰队-后勤] 本周大舰队任务已完成') self._guild_logistics_mission_finished = True return False elif self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=254, count=50): # 0/300 in JP is (255, 255, 255) - logger.info('Guild mission button inactive') + logger.info('[大舰队-后勤] 大舰队任务按钮未激活') return False elif self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=180, count=400): # (255, 255, 255) less than 50, but has many blue-white pixels - logger.info('Guild mission button active') + logger.info('[大舰队-后勤] 大舰队任务按钮已激活') return True elif not self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=180, count=50): # No guild mission counter - logger.info('No guild mission found, mission of this week may not started') + logger.info('[大舰队-后勤] 未找到大舰队任务,本周任务可能未开始') # Guild mission choose in JP server disabled until we get the screenshot. return False # if self.image_color_count(GUILD_MISSION_CHOOSE, color=(255, 255, 255), threshold=221, count=100): @@ -176,7 +214,7 @@ def _guild_logistics_mission_available(self): # logger.info('Guild mission choose not found') # return False else: - logger.info('Unknown guild mission condition. Skipped.') + logger.info('[大舰队-后勤] 未知的大舰队任务条件,跳过') return False @Config.when(SERVER=None) @@ -198,16 +236,16 @@ def _guild_logistics_mission_available(self): r, g, b = get_color(self.device.image, GUILD_MISSION.area) if g > max(r, b) - 10: # Green tick at the bottom right corner if guild mission finished - logger.info('Guild mission has finished this week') + logger.info('[大舰队-后勤] 本周大舰队任务已完成') self._guild_logistics_mission_finished = True return False elif self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=180, count=400): # Unfinished mission accept/collect range from about 240 to 322 - logger.info('Guild mission button active') + logger.info('[大舰队-后勤] 大舰队任务按钮已激活') return True elif not self.image_color_count(GUILD_MISSION, color=(255, 255, 255), threshold=180, count=50): # No guild mission counter - logger.info('No guild mission found, mission of this week may not started') + logger.info('[大舰队-后勤] 未找到大舰队任务,本周任务可能未开始') return False # if self.image_color_count(GUILD_MISSION_CHOOSE, color=(255, 255, 255), threshold=221, count=100): # # Guild mission choose available if user is guild master @@ -217,7 +255,7 @@ def _guild_logistics_mission_available(self): # logger.info('Guild mission choose not found') # return False else: - logger.info('Guild mission button inactive') + logger.info('[大舰队-后勤] 大舰队任务按钮未激活') return False def _guild_logistics_supply_available(self): @@ -239,10 +277,10 @@ def _guild_logistics_supply_available(self): if np.max(color) > np.mean(color) + 25: # For members, click to receive supply # For leaders, click to buy supply and receive supply - logger.info('Guild supply button active') + logger.info('[大舰队-后勤] 大舰队补给按钮已激活') return True else: - logger.info('Guild supply button inactive') + logger.info('[大舰队-后勤] 大舰队补给按钮未激活') return False def _handle_guild_fleet_mission_start(self): @@ -309,7 +347,7 @@ def _guild_logistics_supply_handle(self, state, click_interval, result_timer): return True if state['click_count'] >= GUILD_SUPPLY_MAX_RETRY: - logger.warning('Guild supply remains available after retries, skip supply this time') + logger.warning('[大舰队-后勤] 重试后大舰队补给仍可用,本次跳过') self._guild_logistics_supply_check_finished(state) return False @@ -437,8 +475,8 @@ def _guild_logistics_collect(self, skip_first_screenshot=True): in: GUILD_LOGISTICS out: GUILD_LOGISTICS """ - logger.hr('Guild logistics') - logger.attr('Guild master/official', self.config.GuildLogistics_SelectNewMission) + logger.hr('大舰队后勤') + logger.attr('舰队司令/副司令', self.config.GuildLogistics_SelectNewMission) confirm_timer = Timer(1.5, count=3).start() exchange_interval = Timer(1.5, count=3) click_interval = Timer(0.5, count=1) @@ -509,7 +547,7 @@ def _guild_exchange_scan(self): item.enough = not self.image_color_count(area, color=(255, 93, 90), threshold=221, count=20) text = [str(item.name) if item.enough else f'{item.name} (not enough)' for item in items] - logger.info(f'Exchange items: {", ".join(text)}') + logger.info(f'[大舰队-后勤] 兑换物品: {", ".join(text)}') return items def _guild_exchange(self): @@ -532,7 +570,7 @@ def _guild_exchange(self): items = self._guild_exchange_scan() EXCHANGE_FILTER.load(self.config.GuildLogistics_ExchangeFilter) selected = EXCHANGE_FILTER.apply(items, func=lambda item: item.enough) - logger.attr('Exchange_sort', ' > '.join([str(item.name) for item in selected])) + logger.attr('兑换排序', ' > '.join([str(item.name) for item in selected])) if len(selected): button = EXCHANGE_BUTTONS.buttons[items.index(selected[0])] @@ -540,7 +578,7 @@ def _guild_exchange(self): self.device.click(button) return True else: - logger.warning('No guild exchange items satisfy current filter, or not having enough resources') + logger.warning('[大舰队-后勤] 没有符合当前筛选条件的兑换物品,或资源不足') return False def guild_logistics(self): @@ -554,10 +592,10 @@ def guild_logistics(self): in: page_guild out: page_guild, GUILD_LOGISTICS """ - logger.hr('Guild logistics', level=1) + logger.hr('大舰队后勤', level=1) self.guild_side_navbar_ensure(bottom=3) self._guild_logistics_ensure() result = self._guild_logistics_collect() - logger.info(f'Guild logistics run success: {result}') + logger.info(f'[大舰队-后勤] 后勤运行成功: {result}') return result diff --git a/module/guild/operations.py b/module/guild/operations.py index 0c76d4cb7..ad3f307e0 100644 --- a/module/guild/operations.py +++ b/module/guild/operations.py @@ -1,3 +1,7 @@ +"""大舰队作战处理器,管理作战进入、派遣舰队和进度检测。 +使用 OCR 识别作战进度,支持自动选择新作战。 +""" + from module.base.button import ButtonGrid from module.base.timer import Timer from module.base.utils import * @@ -22,7 +26,7 @@ def _guild_operations_ensure(self, skip_first_screenshot=True): Returns: bool: True 表示成功进入作战,False 表示资金不足。 """ - logger.attr('Guild master/official', self.config.GuildOperation_SelectNewOperation) + logger.attr('大舰队指挥官/官员', self.config.GuildOperation_SelectNewOperation) confirm_timer = Timer(1.5, count=3).start() click_count = 0 while 1: @@ -37,9 +41,9 @@ def _guild_operations_ensure(self, skip_first_screenshot=True): # 可能是因为大舰队作战已被其他军官开启。 # 重新进入大舰队页面应该可以修复此问题。 logger.warning( - 'Unable to start/join guild operation, ' - 'probably because guild operation has been started by another guild officer already') - raise GameBugError('Unable to start/join guild operation') + '[大舰队-作战] 无法启动/加入大舰队作战,' + '可能是因为大舰队作战已被其他军官开启') + raise GameBugError('[大舰队-作战] 无法启动/加入大舰队作战') if self._guild_operation_fund_insufficient(): return False @@ -48,18 +52,18 @@ def _guild_operations_ensure(self, skip_first_screenshot=True): continue if self.appear(GUILD_OPERATIONS_JOIN, interval=3): if self.image_color_count(GUILD_OPERATIONS_MONTHLY_COUNT, color=(255, 93, 90), threshold=221, count=20): - logger.info('Unable to join operation, no more monthly attempts left') + logger.info('[大舰队-作战] 无法加入作战,本月尝试次数已用完') self.device.click(GUILD_OPERATIONS_CLICK_SAFE_AREA) else: current, remain, total = GUILD_OPERATIONS_PROGRESS.ocr(self.device.image) threshold = total * self.config.GuildOperation_JoinThreshold if current <= threshold: - logger.info('Joining Operation, current progress less than ' - f'threshold ({threshold:.2f})') + logger.info('[大舰队-作战] 加入作战,当前进度低于' + f'阈值 ({threshold:.2f})') self.device.click(GUILD_OPERATIONS_JOIN) else: - logger.info('Refrain from joining operation, current progress exceeds ' - f'threshold ({threshold:.2f})') + logger.info('[大舰队-作战] 不加入作战,当前进度超过' + f'阈值 ({threshold:.2f})') self.device.click(GUILD_OPERATIONS_CLICK_SAFE_AREA) confirm_timer.reset() continue @@ -68,9 +72,9 @@ def _guild_operations_ensure(self, skip_first_screenshot=True): confirm_timer.reset() continue if self.handle_popup_single('FLEET_UPDATED'): - logger.info('Fleet composition altered, may still be dispatch-able. However ' - 'fellow guild members have updated their support line up. ' - 'Suggestion: Enable Boss Recommend') + logger.info('[大舰队-作战] 舰队编成已变更,可能仍可派遣。' + '但是大舰队成员已更新了他们的支援阵容。' + '建议:启用Boss推荐') confirm_timer.reset() continue @@ -96,7 +100,7 @@ def _handle_guild_operations_start(self): today = get_server_monthday() limit = self.config.GuildOperation_NewOperationMaxDate if today >= limit: - logger.info(f'No new guild operations because, today\'s date {today} >= limit {limit}') + logger.info(f'[大舰队-作战] 今日日期 {today} >= 限制 {limit},不开启新作战') return False # 硬编码选择奖励最丰厚的作战:所罗门海空战 @@ -127,7 +131,7 @@ def _guild_operation_fund_insufficient(self): if not self.appear(GUILD_OPERATIONS_NEW, offset=(20, 20)): return False if self.image_color_count(GUILD_OPERATION_FUND_CHECK, color=(255, 93, 91), threshold=180, count=30): - logger.warning('Insufficient guild fund to start new operation') + logger.warning('[大舰队-作战] 大舰队资金不足,无法开始新作战') return True return False @@ -152,16 +156,16 @@ def _guild_operations_get_mode(self): 'an operation difficulty') return 0 elif self.appear(GUILD_OPERATIONS_ACTIVE_CHECK): - logger.info('Mode: Operations Active, may proceed to scan and dispatch fleets') + logger.info('[大舰队-作战] 模式: 作战进行中,可扫描和派遣舰队') return 1 elif self.appear(GUILD_BOSS_ENTER): - logger.info('Mode: Guild Raid Boss (GUILD_BOSS_ENTER)') + logger.info('[大舰队-作战] 模式: 大舰队突袭Boss (GUILD_BOSS_ENTER)') return 2 elif self.appear(GUILD_OPERATIONS_NEW, offset=(20, 20)): - logger.info('Mode: Guild Raid Boss (GUILD_OPERATIONS_NEW)') + logger.info('[大舰队-作战] 模式: 大舰队突袭Boss (GUILD_OPERATIONS_NEW)') return 2 else: - logger.warning('Operations interface is unrecognized') + logger.warning('[大舰队-作战] 作战界面无法识别') return None def _guild_operations_get_entrance(self): @@ -185,7 +189,7 @@ def _guild_operations_get_entrance(self): list_expand = [] list_enter = [] dots = TEMPLATE_OPERATIONS_RED_DOT.match_multi(self.image_crop(detection_area, copy=False), threshold=5) - logger.info(f'Active operations found: {len(dots)}') + logger.info(f'[大舰队-作战] 找到进行中的作战: {len(dots)}') for button in dots: button = button.move(vector=detection_area[:2]) expand = button.crop(area=(-257, 14, 12, 51), name='DISPATCH_ENTRANCE_1') @@ -231,7 +235,7 @@ def _guild_operations_dispatch_swipe(self, forward=True, skip_first_screenshot=T self.device.drag(p1, p2, segments=2, shake=(0, 25), point_random=(0, 0, 0, 0), shake_random=(0, -5, 0, 5)) # self.device.sleep(0.3) - logger.warning('Failed to find active operation dispatch') + logger.warning('[大舰队-作战] 未找到进行中的作战派遣') return False def _guild_operations_dispatch_enter(self, skip_first_screenshot=True): @@ -323,9 +327,9 @@ def _guild_operations_get_dispatch(self): # 日志示例:| 1 | | 2 | [ 3 ] text = ' '.join(text) - logger.attr('Dispatch_fleet', text) + logger.attr('派遣舰队', text) if text.endswith(']'): - logger.info('Already at the most right fleet') + logger.info('[大舰队-作战] 已在最右侧舰队') return None else: return button @@ -348,7 +352,7 @@ def _guild_operations_dispatch_switch_fleet(self, skip_first_screenshot=True): if button is None: break elif point_in_area((640, 393), button.area): - logger.info('Dispatching the first fleet, skip switching') + logger.info('[大舰队-作战] 派遣第一支舰队,跳过切换') else: self.device.click(button) # 等待点击动画完成,否则会干扰 _guild_operations_get_dispatch() 的检测 @@ -391,7 +395,7 @@ def _guild_operations_dispatch_execute(self, skip_first_screenshot=True): # 结束 if self.appear(GUILD_DISPATCH_IN_PROGRESS): # 首次派遣时,会显示 GUILD_DISPATCH_IN_PROGRESS - logger.info('Fleet dispatched, dispatch in progress') + logger.info('[大舰队-作战] 舰队已派遣,派遣进行中') break if dispatched and self.appear(GUILD_DISPATCH_FLEET, offset=(20, 20), interval=3): # GUILD_DISPATCH_FLEET 和 GUILD_DISPATCH_FLEET_UNFILLED 特征相同但颜色不同 @@ -401,7 +405,7 @@ def _guild_operations_dispatch_execute(self, skip_first_screenshot=True): # 无法确认舰队是否已派遣, # 因为点击推荐后派遣前也会显示 GUILD_DISPATCH_FLEET # _guild_operations_dispatch() 会在未派遣时重试 - logger.info('Fleet dispatched') + logger.info('[大舰队-作战] 舰队已派遣') break def _guild_operations_dispatch_exit(self, skip_first_screenshot=True): @@ -441,7 +445,7 @@ def _guild_operations_dispatch(self): in: page_guild, guild operation, operation map (GUILD_OPERATIONS_ACTIVE_CHECK) out: page_guild, guild operation, operation map (GUILD_OPERATIONS_ACTIVE_CHECK) """ - logger.hr('Guild dispatch') + logger.hr('大舰队派遣') success = False for _ in reversed(range(2)): if self._guild_operations_dispatch_swipe(forward=_): @@ -462,7 +466,7 @@ def _guild_operations_dispatch(self): else: return True - logger.warning('Too many trials on guild operation dispatch') + logger.warning('[大舰队-作战] 大舰队作战派遣尝试过多') return False def _guild_operations_boss_preparation(self, az, skip_first_screenshot=True): @@ -493,8 +497,8 @@ def _guild_operations_boss_preparation(self, az, skip_first_screenshot=True): self.device.click(GUILD_DISPATCH_FLEET) dispatch_count += 1 else: - logger.warning('Fleet composition error. Preloaded guild support selection may be ' - 'preventing dispatch. Suggestion: Enable Boss Recommend') + logger.warning('[大舰队-作战] 舰队编成错误。预加载的大舰队支援选择可能' + '正在阻止派遣。建议:启用Boss推荐') return False continue @@ -515,7 +519,7 @@ def _guild_operations_boss_preparation(self, az, skip_first_screenshot=True): # 结束 pause = az.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) return True def _guild_operations_boss_combat(self): @@ -533,7 +537,7 @@ def _guild_operations_boss_combat(self): return False az.combat_execute(auto='combat_auto', submarine='every_combat') az.combat_status(expected_end='in_ui') - logger.info('Guild Raid Boss has been repelled') + logger.info('[大舰队-作战] 大舰队突袭Boss已被击退') return True def _guild_operations_boss_available(self): @@ -545,17 +549,17 @@ def _guild_operations_boss_available(self): """ appear = self.image_color_count(GUILD_BOSS_AVAILABLE, color=(140, 243, 99), threshold=221, count=10) if appear: - logger.info('Guild boss available') + logger.info('[大舰队-作战] 大舰队Boss可用') else: - logger.info('Guild boss not available') + logger.info('[大舰队-作战] 大舰队Boss不可用') return appear def guild_operations(self): - logger.hr('Guild operations', level=1) + logger.hr('大舰队作战', level=1) self.guild_side_navbar_ensure(bottom=1) entered = self._guild_operations_ensure() if not entered: - logger.info(f'Guild operation run success: {entered}') + logger.info(f'[大舰队-作战] 大舰队作战运行成功: {entered}') return False # 判断作战模式,目前有 3 种 operations_mode = self._guild_operations_get_mode() @@ -571,9 +575,9 @@ def guild_operations(self): if self.config.GuildOperation_AttackBoss: result = self._guild_operations_boss_combat() else: - logger.info('Auto-battle disabled, play manually to complete this Guild Task') + logger.info('[大舰队-作战] 自动战斗已禁用,需手动完成此大舰队任务') else: result = False - logger.info(f'Guild operation run success: {result}') + logger.info(f'[大舰队-作战] 大舰队作战运行成功: {result}') return result diff --git a/module/handler/ambush.py b/module/handler/ambush.py index 3255c06ef..1a9964760 100644 --- a/module/handler/ambush.py +++ b/module/handler/ambush.py @@ -1,3 +1,5 @@ +"""伏击和空袭处理器。处理地图探索中的伏击回避/迎击和空袭等待。""" + from module.base.timer import Timer from module.base.utils import get_color, red_overlay_transparency from module.combat.combat import Combat @@ -12,19 +14,23 @@ class AmbushHandler(Combat): + """伏击和空袭处理器,通过红色覆盖层透明度检测事件。""" MAP_AMBUSH_OVERLAY_TRANSPARENCY_THRESHOLD = 0.40 MAP_AIR_RAID_OVERLAY_TRANSPARENCY_THRESHOLD = 0.35 # 通常值为 (0.50, 0.53) MAP_AIR_RAID_CONFIRM_SECOND = 0.5 def ambush_color_initial(self): + """初始化伏击和空袭的颜色参考值。""" MAP_AMBUSH.load_color(self.device.image) MAP_AIR_RAID.load_color(self.device.image) def _ambush_appear(self): + """检测伏击是否出现。""" return red_overlay_transparency(MAP_AMBUSH.color, get_color(self.device.image, MAP_AMBUSH.area)) > \ self.MAP_AMBUSH_OVERLAY_TRANSPARENCY_THRESHOLD def _air_raid_appear(self): + """检测空袭是否出现。""" return red_overlay_transparency(MAP_AIR_RAID.color, get_color(self.device.image, MAP_AIR_RAID.area)) > \ self.MAP_AIR_RAID_OVERLAY_TRANSPARENCY_THRESHOLD @@ -32,7 +38,7 @@ def _handle_air_raid(self): """ 等待空袭动画消失。 """ - logger.info('Map air raid') + logger.info('[地图-伏击] 空袭') disappear = Timer(self.MAP_AIR_RAID_CONFIRM_SECOND).start() timeout = Timer(2.5, count=2).start() @@ -40,7 +46,7 @@ def _handle_air_raid(self): self.device.screenshot() # 超时处理 if timeout.reached(): - logger.warning('_handle_air_raid timeout, assume air raid disappeared') + logger.warning('[地图-伏击] 空袭处理超时,假设空袭已消失') break # 检测是否消失 if self._air_raid_appear(): @@ -50,7 +56,8 @@ def _handle_air_raid(self): break def _handle_ambush_evade(self): - logger.info('Map ambushed') + """处理伏击回避事件。""" + logger.info('[地图-伏击] 遭遇伏击') # 等待 MAP_AMBUSH_EVADE 出现 self.wait_until_appear(MAP_AMBUSH_EVADE, offset=(30, 30)) self.handle_info_bar() @@ -73,18 +80,19 @@ def _handle_ambush_evade(self): # 处理回避成功和失败 image = info_letter_preprocess(self.image_crop(INFO_BAR_DETECT, copy=False)) if TEMPLATE_AMBUSH_EVADE_SUCCESS.match(image): - logger.attr('Ambush_evade', 'success') + logger.attr('伏击回避', '成功') elif TEMPLATE_AMBUSH_EVADE_FAILED.match(image): - logger.attr('Ambush_evade', 'failed') + logger.attr('伏击回避', '失败') self.combat(expected_end='no_searching', fleet_index=self.fleet_show_index) else: - logger.warning('Unrecognized info when ambush evade.') + logger.warning('[地图-伏击] 无法识别的伏击回避信息') self.ensure_no_info_bar() if self.combat_appear(): self.combat(fleet_index=self.fleet_show_index) def _handle_ambush_attack(self): - logger.info('Map ambushed') + """处理伏击迎击事件。""" + logger.info('[地图-伏击] 遭遇伏击') # 等待 MAP_AMBUSH_ATTACK 出现 self.wait_until_appear(MAP_AMBUSH_ATTACK, offset=(30, 30)) @@ -108,16 +116,18 @@ def _handle_ambush_attack(self): continue # 进入战斗 - logger.attr('Ambush_evade', 'attack') + logger.attr('伏击回避', '迎击') self.combat(expected_end='no_searching', fleet_index=self.fleet_show_index) def _handle_ambush(self): + """根据配置选择回避或迎击。""" if self.config.Campaign_AmbushEvade: return self._handle_ambush_evade() else: return self._handle_ambush_attack() def handle_ambush(self): + """统一的伏击/空袭处理入口。""" if not self.config.MAP_HAS_AMBUSH: return False @@ -135,6 +145,7 @@ def handle_ambush(self): return False def handle_walk_out_of_step(self): + """处理舰队步数不足的提示。""" if not self.config.MAP_HAS_FLEET_STEP: return False if not self.info_bar_count(): @@ -142,7 +153,7 @@ def handle_walk_out_of_step(self): image = info_letter_preprocess(self.image_crop(INFO_BAR_DETECT, copy=False)) if TEMPLATE_MAP_WALK_OUT_OF_STEP.match(image): - logger.warning('Map walk out of step.') + logger.warning('[地图-伏击] 舰队步数不足') self.handle_info_bar() return True diff --git a/module/handler/assets.py b/module/handler/assets.py index 6f0222262..5680ef8bf 100644 --- a/module/handler/assets.py +++ b/module/handler/assets.py @@ -1,3 +1,5 @@ +"""处理器模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/handler/auto_search.py b/module/handler/auto_search.py index f673a931e..ae2ee0839 100644 --- a/module/handler/auto_search.py +++ b/module/handler/auto_search.py @@ -1,3 +1,17 @@ +"""自动搜索处理器。 + +管理游戏的自动搜索(Auto Search)功能,包括: +- 舰队准备界面的侧边栏切换(编队/指挥喵/自动搜索设置) +- 自动搜索设置选项的切换(如舰队1打道中/舰队2打Boss等) +- 地图中自动搜索开关的检测和控制 +- 自动搜索菜单的继续/退出操作 + +自动搜索是碧蓝航线的核心功能之一,允许玩家在通关模式下 +自动进行地图探索,无需手动操作。 + +继承自 EnemySearchingHandler,在 FastForwardHandler 中被进一步扩展。 +""" + import numpy as np from module.base.button import ButtonGrid @@ -8,14 +22,16 @@ from module.logger import logger from module.map.assets import FLEET_PREPARATION_CHECK +# 自动搜索设置按钮列表,对应游戏界面中的 6 个选项 AUTO_SEARCH_SETTINGS = [ - AUTO_SEARCH_SET_MOB, - AUTO_SEARCH_SET_BOSS, - AUTO_SEARCH_SET_ALL, - AUTO_SEARCH_SET_STANDBY, - AUTO_SEARCH_SET_SUB_AUTO, - AUTO_SEARCH_SET_SUB_STANDBY + AUTO_SEARCH_SET_MOB, # 舰队1打道中,舰队2打Boss + AUTO_SEARCH_SET_BOSS, # 舰队1打Boss,舰队2打道中 + AUTO_SEARCH_SET_ALL, # 舰队1全出击,舰队2待命 + AUTO_SEARCH_SET_STANDBY, # 舰队1待命,舰队2全出击 + AUTO_SEARCH_SET_SUB_AUTO, # 潜艇自动呼叫 + AUTO_SEARCH_SET_SUB_STANDBY # 潜艇待命 ] +# 设置名称到按钮索引的映射 dic_setting_name_to_index = { 'fleet1_mob_fleet2_boss': 0, 'fleet1_boss_fleet2_mob': 1, @@ -24,10 +40,22 @@ 'sub_auto_call': 4, 'sub_standby': 5, } +# 按钮索引到设置名称的反向映射 dic_setting_index_to_name = {v: k for k, v in dic_setting_name_to_index.items()} class AutoSearchHandler(EnemySearchingHandler): + """自动搜索功能处理器。 + + 管理舰队准备界面和地图中的自动搜索相关操作。 + 不同服务器的 UI 布局略有差异(侧边栏按钮位置和大小), + 通过 @Config.when 装饰器实现服务器特定的适配。 + + Attributes: + _auto_search_offset (tuple): 自动搜索选项的匹配偏移量。 + _auto_search_menu_offset (tuple): 自动搜索菜单的匹配偏移量, + 当 MULTIPLE_SORTIE 出现时向左偏移 213px。 + """ @Config.when(SERVER='en') def _fleet_sidebar(self): if FLEET_PREPARATION_CHECK.match(self.device.image, offset=(20, 80)): @@ -75,8 +103,8 @@ def _fleet_preparation_get(self): break if not current: - logger.warning('No fleet sidebar active.') - logger.attr('Fleet_sidebar', f'{current}/{total}') + logger.warning('[处理器-自动搜索] 没有活跃的舰队侧边栏') + logger.attr('舰队侧边栏', f'{current}/{total}') return current def fleet_preparation_sidebar_ensure(self, index): @@ -94,7 +122,7 @@ def fleet_preparation_sidebar_ensure(self, index): 超过则返回 False,成功则返回 True。 """ if index <= 0 or index > 5: - logger.warning(f'Sidebar index cannot be ensured, {index}, limit 1 through 5 only') + logger.warning(f'[处理器-自动搜索] 无法确保侧边栏索引,{index},限制为1到5') return False interval = Timer(1, count=2) @@ -108,7 +136,7 @@ def fleet_preparation_sidebar_ensure(self, index): interval.reset() continue else: - logger.warning('Sidebar could not be ensured') + logger.warning('[处理器-自动搜索] 无法确保侧边栏切换') return False def _auto_search_set_click(self, setting): @@ -128,17 +156,17 @@ def _auto_search_set_click(self, setting): active.append(index) if not active: - logger.warning('No active auto search setting found') + logger.warning('[处理器-自动搜索] 未找到活跃的自动搜索设置') return False - logger.attr('Auto_Search_Setting', ', '.join([dic_setting_index_to_name[index] for index in active])) + logger.attr('自动搜索设置', ', '.join([dic_setting_index_to_name[index] for index in active])) if setting not in dic_setting_name_to_index: - logger.warning(f'Unknown auto search setting: {setting}') + logger.warning(f'[处理器-自动搜索] 未知的自动搜索设置: {setting}') target_index = dic_setting_name_to_index[setting] if target_index in active: - logger.info('Selected to the correct auto search setting') + logger.info('[处理器-自动搜索] 已选择正确的自动搜索设置') return True else: self.device.click(AUTO_SEARCH_SETTINGS[target_index]) @@ -168,7 +196,7 @@ def auto_search_setting_ensure(self, setting, skip_first_screenshot=True): return True else: if counter >= 5: - logger.warning('Auto search setting could not be ensured') + logger.warning('[处理器-自动搜索] 无法确保自动搜索设置切换') return False counter += 1 self.device.sleep((0.3, 0.5)) diff --git a/module/handler/enemy_searching.py b/module/handler/enemy_searching.py index 23c524b24..86a9991e3 100644 --- a/module/handler/enemy_searching.py +++ b/module/handler/enemy_searching.py @@ -1,3 +1,12 @@ +"""敌人搜索动画处理器。 + +处理地图移动后出现的敌人搜索动画(侦察动画)。 +当舰队在地图上移动时,游戏会播放敌人搜索动画, +此模块检测动画的出现和消失,确保自动化流程在动画结束后继续。 + +继承自 InfoHandler,与 AutoSearchHandler 配合使用。 +""" + from module.base.decorator import del_cached_property from module.base.timer import Timer from module.exception import CampaignEnd @@ -9,6 +18,22 @@ class EnemySearchingHandler(InfoHandler): + """敌人搜索动画处理器。 + + 检测地图中敌人搜索(侦察)动画的出现和消失,并处理动画期间可能出现的 + 各种异常情况(关卡结束、紧急委托、剧情弹窗等)。 + + 该处理器在地图操作后被调用,等待敌人搜索动画完成后再继续下一步操作。 + + Attributes: + MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD (float): + 红色覆盖层透明度阈值,超过此值认为搜索动画出现。正常值为 (0.70, 0.80)。 + MAP_ENEMY_SEARCHING_TIMEOUT_SECOND (int): + 搜索动画等待超时时间(秒)。 + in_stage_timer (Timer): 关卡页面检测计时器,防止误判。 + stage_entrance: 关卡入口标识。 + map_is_100_percent_clear (bool): 地图是否已 100% 通关,在 fast_forward.py 中被覆盖。 + """ MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD = 0.5 # 通常值为 (0.70, 0.80) MAP_ENEMY_SEARCHING_TIMEOUT_SECOND = 5 in_stage_timer = Timer(0.5, count=2) @@ -17,9 +42,20 @@ class EnemySearchingHandler(InfoHandler): map_is_100_percent_clear = False # 将在 fast_forward.py 中被覆盖 def enemy_searching_color_initial(self): + """初始化敌人搜索动画的颜色参考值。 + + 子类可覆盖此方法,在检测搜索动画前从当前截图加载颜色数据。 + """ pass def enemy_searching_appear(self): + """检测敌人搜索动画是否出现。 + + 通过模板匹配和亮度分析判断屏幕上是否显示了敌人搜索动画。 + + Returns: + bool: 搜索动画是否出现。 + """ if not self.is_in_map(): return False @@ -29,14 +65,30 @@ def enemy_searching_appear(self): return False def handle_enemy_flashing(self): + """等待敌人闪烁动画消失。 + + 在敌人搜索动画结束后,地图上的敌人图标会短暂闪烁。 + 此方法通过固定延时等待闪烁结束。 + """ self.device.sleep(1.2) def handle_in_stage(self): + """检测并处理已返回关卡选择页面的情况。 + + 当战斗结束或地图探索完成后,游戏会返回关卡选择页面。 + 此方法通过计时器避免短暂的画面切换导致误判。 + + Returns: + bool: 始终返回 False(正常情况)。 + + Raises: + CampaignEnd: 确认已回到关卡页面后抛出,终止当前战役流程。 + """ if self.is_in_stage(): if self.in_stage_timer.reached(): - logger.info('In stage.') + logger.info('[处理器-搜索] 已回到关卡页面') self.ensure_no_info_bar(timeout=1.2) - raise CampaignEnd('In stage.') + raise CampaignEnd('[处理器-搜索] 已回到关卡页面') else: return False else: @@ -46,14 +98,23 @@ def handle_in_stage(self): return False def is_in_stage_page(self): + """检测当前是否在关卡选择页面(战役/活动/SP)。 + + Returns: + bool: 是否在关卡选择页面。 + """ for check in [CAMPAIGN_CHECK, EVENT_CHECK, SP_CHECK]: if self.appear(check, offset=(20, 20)): return True return False def is_stage_page_has_entrance(self): - """ - 检查关卡页面是否有关卡入口,即页面是否已完全加载。 + """检查关卡页面是否有关卡入口,即页面是否已完全加载。 + + 通过 OCR 提取关卡名称图像来判断页面加载状态。 + + Returns: + bool: 关卡入口是否可见(页面已完全加载)。 """ # campaign_extract_name_image 位于 CampaignOcr 中 try: @@ -68,6 +129,13 @@ def is_stage_page_has_entrance(self): return True def is_in_stage(self): + """检测当前是否已完全回到关卡选择页面。 + + 组合页面类型检测和关卡入口可见性检测。 + + Returns: + bool: 是否已完全回到关卡页面。 + """ if not self.is_in_stage_page(): return False if not self.is_stage_page_has_entrance(): @@ -75,6 +143,11 @@ def is_in_stage(self): return True def is_in_map(self): + """检测当前是否在地图界面。 + + Returns: + bool: 是否在地图中。 + """ return self.appear(IN_MAP) def is_event_animation(self): @@ -123,7 +196,7 @@ def handle_in_map_with_enemy_searching(self, drop=None): return True # immediately enter submarine combat in W16 if hasattr(self, 'is_combat_loading') and self.is_combat_loading(): - logger.warning('Entered map with is_combat_loading appeared') + logger.warning('[处理器-搜索] 进入地图时出现战斗加载画面') break if self.handle_auto_search_exit(drop=drop): timeout.limit = 10 @@ -156,11 +229,11 @@ def handle_in_map_with_enemy_searching(self, drop=None): self.handle_enemy_flashing() self.device.sleep(0.3) self.device.screenshot() - logger.info('Enemy searching appeared.') + logger.info('[处理器-搜索] 敌人搜索动画已出现') break self.enemy_searching_color_initial() if timeout.reached(): - logger.info('Enemy searching timeout.') + logger.info('[处理器-搜索] 敌人搜索动画超时') break return True @@ -208,7 +281,7 @@ def handle_in_map_no_enemy_searching(self, drop=None): # 结束条件 if timeout.reached(): - logger.info('No enemy searching in map.') + logger.info('[处理器-搜索] 地图中未出现敌人搜索动画') break return True diff --git a/module/handler/fast_forward.py b/module/handler/fast_forward.py index ec4ee2207..034cca27a 100644 --- a/module/handler/fast_forward.py +++ b/module/handler/fast_forward.py @@ -1,3 +1,6 @@ +"""战斗快进处理器模块。定义 FastForwardHandler,管理战斗中的快进开关、舰队锁定、 +自动搜索等开关控件,以及地图文件加载和自动搜索设置。""" + import os import re @@ -38,7 +41,7 @@ def map_files(event): folder = f'./campaign/{event}' if not os.path.exists(folder): - logger.warning(f'Map file folder: {folder} does not exist, can not get map files') + logger.warning(f'地图文件夹: {folder} 不存在,无法获取地图文件') return [] files = [] @@ -171,7 +174,7 @@ def map_get_info(self, star=False): def map_show_info(self): # 记录日志 - logger.attr('MAP_CLEAR_ALL_THIS_TIME', self.config.MAP_CLEAR_ALL_THIS_TIME) + logger.attr('本次全图清除', self.config.MAP_CLEAR_ALL_THIS_TIME) names = ['map_achieved_star_1', 'map_achieved_star_2', 'map_achieved_star_3', 'map_is_100_percent_clear', 'map_is_3_stars', 'map_is_threat_safe', 'map_has_clear_mode'] @@ -179,8 +182,8 @@ def map_show_info(self): log_names = ['_'.join([x for x in name.split('_') if x not in strip]) for name in names] text = ', '.join([l for l, n in zip(log_names, names) if self.__getattribute__(n)]) text = f'{int(self.map_clear_percentage * 100)}%, ' + text - logger.attr('Map_info', text) - logger.attr('StopCondition_MapAchievement', self.config.StopCondition_MapAchievement) + logger.attr('地图信息', text) + logger.attr('地图成就条件', self.config.StopCondition_MapAchievement) def handle_fast_forward(self): if not self.map_has_clear_mode: @@ -202,7 +205,7 @@ def handle_fast_forward(self): self.config.MAP_HAS_DECOY_ENEMY = False self.map_is_clear_mode = True if self.config.MAP_CLEAR_ALL_THIS_TIME: - logger.info('MAP_CLEAR_ALL_THIS_TIME does not work with auto search, disable auto search temporarily') + logger.info('本次全图清除不兼容自动搜索,临时禁用') self.map_is_auto_search = False else: self.map_is_auto_search = self.config.Campaign_UseAutoSearch @@ -237,7 +240,7 @@ def handle_map_fleet_lock(self, enable=None): # 舰队锁定取决于地图上是否显示该选项,而非地图状态 # 因为如果已在地图中,则没有地图状态 if not FLEET_LOCK.appear(main=self): - logger.info('No fleet lock option.') + logger.info('无舰队锁定选项') return False if enable is None: @@ -258,12 +261,12 @@ def map_wait_auto_search(self): timeout = Timer(1, count=3).start() for _ in self.loop(): state = AUTO_SEARCH.get(main=self) - logger.attr('AUTO_SEARCH', state) + logger.attr('自动搜索', state) if state != 'unknown': return True if timeout.reached(): # 部分地图有通关模式但没有自动搜索 - logger.info('map wait auto search timeout') + logger.info('等待自动搜索超时') return False def handle_auto_search(self): @@ -280,13 +283,13 @@ def handle_auto_search(self): # return False current = AUTO_SEARCH.get(main=self) - logger.attr('Auto_Search', current) + logger.attr('自动搜索', current) if current == 'unknown': - logger.info('No auto search option.') + logger.info('无自动搜索选项') return False if self.config.Campaign_UseAutoSearch and not self.map_is_auto_search: - logger.warning('Auto search is enabled but clear mode state was not confirmed, keep auto search enabled') + logger.warning('自动搜索已启用但清除模式未确认,保持启用') self.map_is_auto_search = True state = 'on' if self.map_is_auto_search else 'off' @@ -302,7 +305,7 @@ def _auto_search_set(self, state, current='unknown', skip_first_screenshot=True) 如果开关已开启但模板匹配暂时返回 ``unknown``, 点击目标 ON 区域实际上会将其关闭。 """ - logger.info(f'Auto_Search set to {state}') + logger.info(f'[处理器-快进] 自动搜索设置为 {state}') timeout = Timer(2, count=4).start() click_timer = Timer(1, count=2).clear() changed = False @@ -315,14 +318,14 @@ def _auto_search_set(self, state, current='unknown', skip_first_screenshot=True) self.device.screenshot() current = AUTO_SEARCH.get(main=self) - logger.attr('Auto_Search', current) + logger.attr('自动搜索', current) if current == state: return changed if current == 'unknown': if timeout.reached(): - logger.warning('Auto search switch state unknown, keep current state') + logger.warning('自动搜索开关状态未知,保持当前') return changed continue else: @@ -349,7 +352,7 @@ def handle_auto_search_setting(self): if not self.map_is_auto_search: return False - logger.info('Auto search setting') + logger.info('自动搜索设置') self.fleet_preparation_sidebar_ensure(3) if not self.auto_search_setting_ensure(self.config.Fleet_FleetOrder) \ and self.config.task.command == 'GemsFarming': @@ -390,13 +393,13 @@ def handle_auto_submarine_call_disable(self): return False # 2025.09.22 修正:舰队角色设置在通关模式后才解锁 if not self.map_is_clear_mode: - logger.warning('Can not set submarine call because auto search not available, assuming disabled') - logger.warning('Please do the followings: ' - 'goto any stage -> auto search role -> set submarine role to standby') - logger.warning('If you already did, ignore this warning') + logger.warning('[处理器-快进] 无法设置潜艇呼叫,自动搜索不可用') + logger.warning('[处理器-快进] 请执行以下操作:' + '前往任意关卡 -> 自动搜索角色 -> 设置潜艇角色为待命') + logger.warning('[处理器-快进] 如果已设置,请忽略此警告') return False - logger.info('Disable auto submarine call') + logger.info('禁用自动潜艇呼叫') self.fleet_preparation_sidebar_ensure(3) self.auto_search_setting_ensure('sub_standby') return True @@ -470,14 +473,14 @@ def campaign_name_increase(self, name): return new # 检查地图文件是否存在 existing = map_files(self.config.Campaign_Event) - logger.info(f'Existing files: {existing}') + logger.info(f'现有文件: {existing}') if new.lower() in existing: return new else: - logger.info(f'Stage increase reach end, new map {new} does not exist') + logger.info(f'关卡递增到达终点,新地图 {new} 不存在') return name else: - logger.info('Stage increase reach end') + logger.info('关卡递增到达终点') return name return name @@ -516,10 +519,10 @@ def handle_map_stop(self): prev_stage = to_map_input_name(self.config.Campaign_Name) next_stage = self.campaign_name_increase(prev_stage) if next_stage != prev_stage: - logger.info(f'Stage {prev_stage} increases to {next_stage}') + logger.info(f'[处理器-快进] 关卡 {prev_stage} 推进到 {next_stage}') self.config.Campaign_Name = next_stage else: - logger.info(f'Stage {prev_stage} cannot increase, stop at current stage') + logger.info(f'[处理器-快进] 关卡 {prev_stage} 无法推进,停在当前关卡') self.config.Scheduler_Enable = False else: self.config.Scheduler_Enable = False @@ -561,10 +564,10 @@ def _set_2x_book_status(self, status, check_button, box_button, skip_first_scree clicked_threshold += 1 if not clicked_threshold and confirm_timer.reached(): - logger.info('Map do not have 2x book setting') + logger.info('地图无2倍经验书设置') return False - logger.warning(f'Wait time has expired; Cannot set 2x book setting') + logger.warning(f'等待时间已过,无法设置2倍经验书') return False def handle_2x_book_setting(self, mode='prep'): @@ -580,10 +583,10 @@ def handle_2x_book_setting(self, mode='prep'): if not self.map_is_clear_mode: return False if not hasattr(self, 'emotion'): - logger.info('Emotion instance not loaded, cannot handle 2x book setting') + logger.info('情绪实例未加载,无法处理2倍经验书') return False - logger.info(f'Handling 2x book setting, mode={mode}.') + logger.info(f'[处理器-快进] 处理2倍经验书设置,模式={mode}') if mode == 'prep': book_check = BOOK_CHECK_PREP book_box = BOOK_BOX_PREP @@ -630,10 +633,10 @@ def handle_map_walk_speedup(self, skip_first_screenshot=True): self.device.screenshot() if self.image_color_count(MAP_WALK_SPEEDUP, color=(132, 255, 148), threshold=180, count=50): - logger.attr('Walk_Speedup', 'on') + logger.attr('行走加速', '开启') return True if timeout.reached(): - logger.warning(f'Wait time has expired; Cannot set map walk speedup') + logger.warning(f'等待时间已过,无法设置地图移动加速') return False if interval.reached(): diff --git a/module/handler/info_handler.py b/module/handler/info_handler.py index 3fbd04e68..0431c21c0 100644 --- a/module/handler/info_handler.py +++ b/module/handler/info_handler.py @@ -1,3 +1,21 @@ +"""信息栏和弹窗处理器。 + +处理游戏中各种信息提示和弹窗对话框,是所有处理器的基础组件。 + +信息栏(Info Bar): + 屏幕顶部的通知条,包含委托完成、敌人搜索等提示。 + 通过检测信息栏的出现和消失来同步自动化流程。 + +弹窗处理: + - 确认/取消对话框(如退役确认、战斗确认) + - 活动公告弹窗 + - 紧急委托通知 + - 大舰队相关弹窗 + +预处理函数 info_letter_preprocess(): + 调整信息栏文字图像的对比度,用于模板匹配识别信息栏内容。 +""" + from scipy import signal from module.base.base import ModuleBase @@ -30,8 +48,18 @@ def info_letter_preprocess(image): class InfoHandler(ModuleBase): - """ - 处理游戏中各类弹窗和消息的基类。 + """信息栏和弹窗处理器基类。 + + 提供游戏中各类 UI 弹窗的统一检测和处理接口。 + 所有需要处理弹窗的处理器都应继承此类。 + + 主要功能: + - 信息栏检测和处理(info_bar_count, handle_info_bar) + - 弹窗确认/取消(handle_popup_confirm, handle_popup_cancel) + - 紧急委托处理(handle_urgent_commission) + - 剧情跳过(handle_story_skip) + - 大舰队弹窗处理(handle_guild_popup_cancel) + - 投票弹窗处理(handle_vote_popup) """ """ 信息栏 @@ -162,7 +190,7 @@ def handle_urgent_commission(self, drop=None): """ appear = self.appear(GET_MISSION, offset=True, interval=2) if appear: - logger.info('Get urgent commission') + logger.info('[处理器-委托] 收到紧急委托') if drop: self.handle_info_bar() drop.add(self.device.image) @@ -175,12 +203,12 @@ def handle_urgent_commission(self, drop=None): self._hot_fix_check_wait.clear() if self._hot_fix_check_wait.started() and 3 <= self._hot_fix_check_wait.current_time() <= 6: if not self.device.app_is_running(): - logger.error('Detected hot fixes from game server, game died') + logger.error('[处理器-热更新] 检测到游戏服务器热更新,游戏进程已退出') raise GameNotRunningError # 使用模板匹配(不含颜色匹配),因为维护公告弹窗颜色不同 if self.appear(LOGIN_CHECK, offset=(30, 30)): - logger.warning('Account logged out, ' - 'probably because account kicked by server maintenance or another log in') + logger.warning('[处理器-热更新] 账号已登出,' + '可能是因为服务器维护或另一个登录将账号踢下线') self._hot_fix_check_wait.clear() return appear @@ -464,9 +492,9 @@ def story_skip(self, drop=None): if self._story_option_timer.reached() and self.appear(STORY_SKIP_3, offset=(20, 20), interval=0): options = self._story_option_buttons_2() options_count = len(options) - logger.attr('Story_options', options_count) + logger.attr('剧情选项数量', options_count) if options_count: - logger.attr('Story_option_buttons', [option.button for option in options]) + logger.attr('剧情选项按钮', [option.button for option in options]) if not options_count: self._story_option_record = 0 self._story_option_confirm.reset() @@ -536,7 +564,7 @@ def handle_story_skip(self, drop=None): return self.story_skip(drop=drop) def ensure_no_story(self, skip_first_screenshot=True): - logger.info('Ensure no story') + logger.info('[处理器-剧情] 确保没有剧情') story_timer = Timer(3, count=6).start() while 1: if skip_first_screenshot: @@ -612,9 +640,9 @@ def wait_until_manjuu_disappear(self): timer.reset() else: if timer.reached(): - logger.info(f'Manjuu disappeared') + logger.info('[处理器-加载] 小黄鸡已消失') break - + def handle_manjuu(self): """ 处理小黄鸡加载动画。 @@ -624,7 +652,7 @@ def handle_manjuu(self): """ count = self.manjuu_count() if count > 2: - logger.info(f'Manjuu count: {count}, waiting for manjuu to disappear') + logger.info(f'[处理器-加载] 小黄鸡数量: {count},等待小黄鸡消失') self.wait_until_manjuu_disappear() return True else: diff --git a/module/handler/login.py b/module/handler/login.py index 1d4d255a6..4acb70fa0 100644 --- a/module/handler/login.py +++ b/module/handler/login.py @@ -1,3 +1,18 @@ +"""登录流程处理器。 + +管理碧蓝航线的登录和游戏重启流程,包括: +- 应用启动和登录画面检测 +- 各种登录弹窗处理(公告、活动、签到等) +- 游戏崩溃/卡死时的重启恢复 +- 服务器连接异常处理 + +登录流程覆盖了游戏启动后的各种 UI 状态, +通过截图循环检测并处理所有可能出现的弹窗和确认框, +最终确保游戏回到主界面。 + +继承自 UI,利用页面导航能力处理跨页面的弹窗。 +""" + # 基于原版 login.py 增加了智能的游戏重启逻辑 # 用于处理登录流程中的各种弹窗、公告以及在应用崩溃时执行重启恢复操作。 # 最后更新: 2025-08-25 20:41 @@ -24,6 +39,17 @@ class LoginHandler(UI): + """登录和游戏重启处理器。 + + 处理游戏启动后的登录流程,包括各种弹窗、公告、签到奖励的自动关闭, + 以及游戏崩溃后的重启恢复逻辑。 + + 主要方法: + - _handle_app_login(): 完整的登录流程,从任意页面到主界面 + - app_restart(): 重启游戏应用 + - handle_app_login(): 带重试的登录入口 + """ + def _handle_app_login(self): """ Pages: @@ -35,7 +61,7 @@ def _handle_app_login(self): GameTooManyClickError: 点击次数过多。 GameNotRunningError: 游戏未运行。 """ - logger.hr('App login') + logger.hr('应用登录') confirm_timer = Timer(1.5, count=4).start() orientation_timer = Timer(5) @@ -55,7 +81,7 @@ def _handle_app_login(self): # 结束条件 if self.is_in_main(): if confirm_timer.reached(): - logger.info('Login to main confirm') + logger.info('[登录] 登录到主界面确认') break else: confirm_timer.reset() @@ -64,10 +90,10 @@ def _handle_app_login(self): if self.match_template_color(LOGIN_CHECK, offset=(30, 30), interval=5): self.device.click(LOGIN_CHECK) if not login_success: - logger.info('Login success') + logger.info('[登录] 登录成功') login_success = True if self.appear(ANDROID_NO_RESPOND, offset=(30, 30), interval=5): - logger.warning('Emulator no respond') + logger.warning('[登录] 模拟器无响应') self.device.click_record_add(ANDROID_NO_RESPOND) self.device.click_record_check() self.device.click(ANDROID_NO_RESPOND, control_check=False) @@ -152,7 +178,7 @@ def handle_app_login(self): GameTooManyClickError: 点击次数过多。 GameNotRunningError: 游戏未运行。 """ - logger.info('handle_app_login') + logger.info('[登录] 处理应用登录') self.device.screenshot_interval_set(1.0) try: self._handle_app_login() @@ -160,11 +186,11 @@ def handle_app_login(self): self.device.screenshot_interval_set() def app_stop(self): - logger.hr('App stop') + logger.hr('应用停止') self.device.app_stop() def app_start(self): - logger.hr('App start') + logger.hr('应用启动') self.device.app_start() self.handle_app_login() # self.ensure_no_unfinished_campaign() @@ -178,7 +204,7 @@ def app_start(self): # self.config.task_delay(server_update=True) def app_restart(self): - logger.hr('App restart') + logger.hr('应用重启') # 智能的多次尝试重启逻辑 RESTART_TRIES = 4 FIRST_TRY_WAIT_SECONDS = 30 @@ -188,31 +214,31 @@ def app_restart(self): clear_cache = getattr(self.config, 'Restart_ClearCache', False) for i in range(RESTART_TRIES): - logger.info(f"App restart attempt {i + 1}/{RESTART_TRIES}...") + logger.info(f"[重启] 应用重启尝试 {i + 1}/{RESTART_TRIES}...") self.device.app_stop() if clear_cache: self.device.app_clear() self.device.sleep(3) self.device.app_start() wait_seconds = FIRST_TRY_WAIT_SECONDS if i == 0 else SUBSEQUENT_TRY_WAIT_SECONDS - logger.info(f"Waiting {wait_seconds} seconds for app to launch and stabilize...") + logger.info(f"[重启] 等待 {wait_seconds} 秒让应用启动和稳定...") self.device.sleep(wait_seconds) # 验证应用是否已运行 if self.device.app_is_running(): - logger.info(">>> App started successfully and is running.") + logger.info("[重启] 应用启动成功并正在运行") is_restart_success = True break # 成功启动,跳出循环 else: - logger.warning(f"Attempt {i + 1} failed. App is not running after launch (likely crashed).") + logger.warning(f"[重启] 尝试 {i + 1} 失败。应用启动后未运行(可能崩溃)") if i < RESTART_TRIES - 1: - logger.info("Retrying...") - + logger.info("[重启] 重试中...") + # 所有尝试均失败则抛出异常 if not is_restart_success: - logger.critical(f"[Handler] 重试 {RESTART_TRIES} 次了!还是死活起不来,你的运行环境是碳基生物能搞出来的?") + logger.critical(f"[重启] 重试 {RESTART_TRIES} 次了!还是死活起不来,你的运行环境是碳基生物能搞出来的?") from module.exception import RequestHumanTakeover - raise RequestHumanTakeover("App restart failed repeatedly") + raise RequestHumanTakeover("[重启] 应用重启多次失败") self.handle_app_login() # self.ensure_no_unfinished_campaign() @@ -299,8 +325,8 @@ def handle_user_agreement(self, xp, hierarchy): peaks = (peaks[0] + peaks[1]) / 2 start_pos = [(start_padding_results[2] + start_margin_results[2]) / 2, float(peaks)] end_pos = [(start_padding_results[2] + start_margin_results[2]) / 2, area_wait_results[3]] - logger.info("user agreement position find result: " + ', '.join(f'{pos:.2f}' for pos in start_pos)) - logger.info("user agreement area expect: " + 'x:963-973, y:259-279') + logger.info("[登录-协议] 用户协议位置查找结果: " + ', '.join(f'{pos:.2f}' for pos in start_pos)) + logger.info("[登录-协议] 用户协议区域预期: " + 'x:963-973, y:259-279') self.device.drag(start_pos, end_pos, segments=2, shake=(0, 25), point_random=(0, 0, 0, 0), shake_random=(0, -5, 0, 5)) diff --git a/module/handler/mystery.py b/module/handler/mystery.py index a7351fb86..3f1482724 100644 --- a/module/handler/mystery.py +++ b/module/handler/mystery.py @@ -1,3 +1,14 @@ +"""神秘格子(Mystery Node)处理器。 + +处理地图上的神秘格子事件,包括: +- 获取道具(装备箱、材料等) +- 获取弹药补给 +- 获取航空支援(航母编队) + +继承自 StrategyHandler 和 EnemySearchingHandler, +可在地图操作的状态循环中直接调用。 +""" + from module.base.timer import Timer from module.base.utils import area_cross_area from module.combat.assets import GET_ITEMS_1 @@ -8,14 +19,32 @@ class MysteryHandler(StrategyHandler, EnemySearchingHandler): + """神秘格子事件处理器。 + + 处理地图中踩到神秘格子(问号格子)后触发的各类事件。 + 神秘格子可能给予道具、弹药或航空支援。 + + Attributes: + _get_ammo_log_timer (Timer): 弹药获取日志节流计时器, + 避免频繁记录相同的弹药获取日志。 + carrier_count (int): 本次地图中获取的航空支援次数。 + """ _get_ammo_log_timer = Timer(3) carrier_count = 0 def handle_mystery(self, button=None): - """ + """处理神秘格子事件的统一入口。 + + 依次检测道具获取、弹药获取和航空支援三种事件类型。 + Args: - button (optional): Button to click when get_items. - Can be destination grid which makes the bot more like human. + button (Button | None): 获取道具时点击的按钮。 + 可以是目标格子,使操作更接近人类行为。 + 为 None 时使用默认的 MYSTERY_ITEM 按钮。 + + Returns: + str | bool: 事件类型字符串('get_item'、'get_ammo'、'get_carrier'), + 未检测到任何事件时返回 False。 """ with self.stat.new( genre=self.config.campaign_name, method=self.config.DropRecord_CombatRecord @@ -30,14 +59,17 @@ def handle_mystery(self, button=None): return False def handle_mystery_items(self, button=None, drop=None): - """ + """处理神秘格子的道具获取事件。 + + 检测 "获得道具" 界面,记录掉落并关闭界面。 + Args: - button: Button to click when get_items. - Can be destination grid which makes the bot more like human. - drop (DropImage): + button (Button | None): 点击按钮。当 `MAP_MYSTERY_MAP_CLICK` 关闭时 + 使用默认 MYSTERY_ITEM 按钮。 + drop (DropImage | None): 掉落记录对象。 Returns: - bool: If handled. + bool: 是否处理了道具获取事件。 """ if not self.config.MAP_MYSTERY_MAP_CLICK: button = MYSTERY_ITEM @@ -45,7 +77,7 @@ def handle_mystery_items(self, button=None, drop=None): button = MYSTERY_ITEM if self.appear(GET_ITEMS_1, offset=5): - logger.attr('Mystery', 'Get item') + logger.attr('神秘格子', '获得道具') if drop: drop.add(self.device.image) self.device.click(button) @@ -57,16 +89,19 @@ def handle_mystery_items(self, button=None, drop=None): return False def handle_mystery_ammo(self, drop=None): - """ + """处理神秘格子的弹药补给事件。 + + 检测信息栏中的弹药获取提示,记录掉落。 + Args: - drop (DropImage): + drop (DropImage | None): 掉落记录对象。 Returns: - bool: If handled. + bool: 是否检测到弹药获取。 """ if self.info_bar_count(): if self._get_ammo_log_timer.reached() and self.appear(GET_AMMO): - logger.attr('Mystery', 'Get ammo') + logger.attr('神秘格子', '获得弹药') self._get_ammo_log_timer.reset() if drop: drop.add(self.device.image) @@ -75,16 +110,20 @@ def handle_mystery_ammo(self, drop=None): return False def handle_mystery_carrier(self, drop=None): - """ + """处理神秘格子的航空支援(航母编队)事件。 + + 当配置允许时,检测地图中出现的敌人搜索动画(航母加入), + 等待动画完成并记录掉落。 + Args: - drop (DropImage): + drop (DropImage | None): 掉落记录对象。 Returns: - bool: If handled. + bool: 是否处理了航空支援事件。 """ if self.config.MAP_MYSTERY_HAS_CARRIER: if self.is_in_map() and self.enemy_searching_appear(): - logger.attr('Mystery', 'Get carrier') + logger.attr('神秘格子', '获得航母支援') self.carrier_count += 1 if drop: drop.add(self.device.image) diff --git a/module/handler/sensitive_info.py b/module/handler/sensitive_info.py index 347ea0db3..1e230dfb9 100644 --- a/module/handler/sensitive_info.py +++ b/module/handler/sensitive_info.py @@ -1,21 +1,41 @@ +"""敏感信息遮罩模块。 + +在截图和日志输出前遮罩用户的敏感信息,保护隐私安全。 + +图像遮罩: +- 主界面:遮罩指挥官名称、UID 等个人信息 +- 玩家信息页面:遮罩玩家 ID 和服务器信息 + +文本遮罩: +- 日志中的文件路径替换为假路径(C:\\fakepath\\AzurLaneAutoScript) +- ADB 路径中的真实路径被替换 + +使用 Mask 类实现区域遮罩,遮罩图像存储在 assets/mask/ 目录下。 +""" + import re from module.base.mask import Mask from module.ui.assets import PLAYER_CHECK from module.ui.page import MAIN_GOTO_CAMPAIGN_WHITE, MAIN_GOTO_FLEET +# 遮罩模板图像 MASK_MAIN = Mask('./assets/mask/MASK_MAIN.png') MASK_MAIN_WHITE = Mask('./assets/mask/MASK_MAIN_WHITE.png') MASK_PLAYER = Mask('./assets/mask/MASK_PLAYER.png') def handle_sensitive_image(image): - """ + """对截图中的敏感信息区域应用遮罩。 + + 检测当前截图是否包含敏感信息页面(主界面、玩家信息等), + 如果是则应用对应的遮罩模板。 + Args: - image: + image (np.ndarray): 输入截图。 Returns: - np.ndarray: + np.ndarray: 应用遮罩后的截图。 """ if PLAYER_CHECK.match(image, offset=(30, 30)): image = MASK_PLAYER.apply(image) @@ -28,12 +48,15 @@ def handle_sensitive_image(image): def handle_sensitive_text(text): - """ + """对日志文本中的敏感路径信息进行脱敏处理。 + + 将日志中的真实文件路径替换为假路径,防止泄露用户的目录结构信息。 + Args: - text (str): + text (str): 输入文本。 Returns: - str: + str: 脱敏后的文本。 """ text = re.sub('File \"(.*?)AzurLaneAutoScript', 'File \"C:\\\\fakepath\\\\AzurLaneAutoScript', text) text = re.sub('\[Adb_binary\] (.*?)AzurLaneAutoScript', '[Adb_binary] C:\\\\fakepath\\\\AzurLaneAutoScript', text) diff --git a/module/handler/strategy.py b/module/handler/strategy.py index dc51c4e2a..40277ef32 100644 --- a/module/handler/strategy.py +++ b/module/handler/strategy.py @@ -1,3 +1,6 @@ +"""战斗策略面板处理器模块。定义 StrategyHandler,管理战斗中的阵型选择、潜艇搜索/视角开关、 +机动回避、空袭等策略操作。""" + from module.combat.assets import GET_ITEMS_1 from module.handler.assets import * from module.handler.info_handler import InfoHandler @@ -29,7 +32,7 @@ class StrategyHandler(InfoHandler): fleet_2_formation_fixed = False def strategy_open(self, skip_first_screenshot=True): - logger.info('Strategy open') + logger.info('[策略-面板] 打开策略面板') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -48,7 +51,7 @@ def strategy_open(self, skip_first_screenshot=True): continue def strategy_close(self, skip_first_screenshot=True): - logger.info('Strategy close') + logger.info('[策略-面板] 关闭策略面板') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -73,7 +76,7 @@ def strategy_set_execute(self, formation=None, sub_view=None, sub_hunt=None): Pages: in: STRATEGY_OPENED """ - logger.info(f'Strategy set: formation={formation}, submarine_view={sub_view}, submarine_hunt={sub_hunt}') + logger.info(f'[策略-设置] 设置: 阵型={formation}, 潜艇视图={sub_view}, 潜艇狩猎={sub_hunt}') if formation is not None: FORMATION.set(formation, main=self) @@ -87,12 +90,12 @@ def strategy_set_execute(self, formation=None, sub_view=None, sub_hunt=None): if SUBMARINE_VIEW.appear(main=self): SUBMARINE_VIEW.set('on' if sub_view else 'off', main=self) else: - logger.warning('Setting up submarine_view but no icon appears') + logger.warning('[策略-设置] 设置潜艇视图但图标未出现') if sub_hunt is not None: if SUBMARINE_HUNT.appear(main=self): SUBMARINE_HUNT.set('on' if sub_hunt else 'off', main=self) else: - logger.warning('Setting up submarine_hunt but no icon appears') + logger.warning('[策略-设置] 设置潜艇狩猎但图标未出现') def handle_strategy(self, index): """ @@ -108,7 +111,7 @@ def handle_strategy(self, index): return False expected_formation = self.config.__getattribute__(f'Fleet_Fleet{index}Formation') if self._strategy_get_from_map_buff() == expected_formation and not self.config.Submarine_Fleet: - logger.info('Skip strategy bar check.') + logger.info('[策略-设置] 跳过策略栏检查') self.__setattr__(f'fleet_{index}_formation_fixed', True) return False @@ -139,7 +142,7 @@ def _strategy_get_from_map_buff(self): else: buff = 'unknown' - logger.attr('Map_buff', buff) + logger.attr('地图增益', buff) return buff def is_in_strategy_submarine_move(self): @@ -159,7 +162,7 @@ def strategy_submarine_move_enter(self, skip_first_screenshot=True): in: STRATEGY_OPENED, SUBMARINE_MOVE_ENTER out: SUBMARINE_MOVE_CONFIRM """ - logger.info('Submarine move enter') + logger.info('潜艇移动进入') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -180,7 +183,7 @@ def strategy_submarine_move_confirm(self, skip_first_screenshot=True): in: SUBMARINE_MOVE_CONFIRM out: STRATEGY_OPENED, SUBMARINE_MOVE_ENTER """ - logger.info('Submarine move confirm') + logger.info('潜艇移动确认') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -203,7 +206,7 @@ def strategy_submarine_move_cancel(self, skip_first_screenshot=True): in: SUBMARINE_MOVE_CONFIRM out: STRATEGY_OPENED, SUBMARINE_MOVE_ENTER """ - logger.info('Submarine move cancel') + logger.info('潜艇移动取消') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -248,7 +251,7 @@ def strategy_mob_move_enter(self, skip_first_screenshot=True): in: STRATEGY_OPENED, MOB_MOVE_ENTER out: MOB_MOVE_CANCEL """ - logger.info('Mob move enter') + logger.info('舰队移动进入') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -269,7 +272,7 @@ def strategy_mob_move_cancel(self, skip_first_screenshot=True): in: MOB_MOVE_CANCEL out: STRATEGY_OPENED, MOB_MOVE_ENTER """ - logger.info('Mob move cancel') + logger.info('舰队移动取消') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -302,7 +305,7 @@ def strategy_air_strike_enter(self, skip_first_screenshot=True): in: STRATEGY_OPENED, AIR_STRIKE_ENTER out: AIR_STRIKE_CONFIRM """ - logger.info('Air strike enter') + logger.info('空袭进入') for _ in self.loop(skip_first=skip_first_screenshot): if self.appear(AIR_STRIKE_CONFIRM, offset=(20, 20)): break @@ -315,7 +318,7 @@ def strategy_air_strike_cancel(self, skip_first_screenshot=True): in: AIR_STRIKE_CONFIRM out: STRATEGY_OPENED, AIR_STRIKE_ENTER """ - logger.info('Air strike cancel') + logger.info('空袭取消') for _ in self.loop(skip_first=skip_first_screenshot): if self.appear(AIR_STRIKE_ENTER, offset=(150, 200)): break diff --git a/module/hard/assets.py b/module/hard/assets.py index 4fd1c45c8..327cf5d9c 100644 --- a/module/hard/assets.py +++ b/module/hard/assets.py @@ -1,3 +1,5 @@ +"""困难模式模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/hard/equipment.py b/module/hard/equipment.py index 2506baa51..7ca0fc194 100644 --- a/module/hard/equipment.py +++ b/module/hard/equipment.py @@ -1,3 +1,10 @@ +"""困难模式装备管理模块。 + +处理困难关卡中舰队装备的自动装卸。根据配置的舰队编号 +选择对应的装备入口,继承通用装备管理逻辑,实现在进入 +困难关卡前自动换装、通关后还原装备的流程。 +""" + from module.equipment.equipment import Equipment from module.hard.assets import * from module.map.assets import * diff --git a/module/hard/hard.py b/module/hard/hard.py index 8b1b5396b..5f0130019 100644 --- a/module/hard/hard.py +++ b/module/hard/hard.py @@ -1,3 +1,12 @@ +"""困难模式战役执行模块。 + +自动执行碧蓝航线的困难模式关卡。困难模式与普通模式共享同一张地图, +但使用独立的战役入口和额外的限制条件(如每日出击次数、舰队锁定等)。 +通过 OCR 识别剩余出击次数,循环执行直到用尽。 + +配置路径: Hard.HardStage (关卡选择), Hard.HardFleet (舰队选择) +""" + import importlib from campaign.campaign_hard.campaign_hard import Campaign @@ -11,11 +20,21 @@ class CampaignHard(CampaignRun): + """困难模式战役执行器。 + + 继承自 CampaignRun,负责执行困难模式关卡。从普通模式战役加载地图数据, + 强制启用舰队锁定和自动搜索,通过 OCR 识别每日剩余出击次数并循环执行。 + + Attributes: + equipment_has_take_on: 装备是否已穿戴(当前未使用)。 + campaign: 战役执行实例,由 CampaignRun 提供。 + """ + equipment_has_take_on = False campaign: Campaign def run(self): - logger.hr('Campaign hard', level=1) + logger.hr('困难战役', level=1) name = to_map_file_name(self.config.Hard_HardStage) self.config.override( Campaign_Mode='hard', @@ -42,7 +61,7 @@ def run(self): # 执行 remain = OCR_HARD_REMAIN.ocr(self.device.image) - logger.attr('Remain', remain) + logger.attr('剩余次数', remain) for n in range(remain): self.campaign.run() diff --git a/module/island/assets.py b/module/island/assets.py index 5db1d9697..12ee67171 100644 --- a/module/island/assets.py +++ b/module/island/assets.py @@ -1,3 +1,5 @@ +"""岛屿模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island/island.py b/module/island/island.py index 6adc0f935..12d5b9c2a 100644 --- a/module/island/island.py +++ b/module/island/island.py @@ -1,3 +1,9 @@ +"""岛屿系统基类模块。 + +提供岛屿自动化操作的核心基础功能,包括岛屿导航、岗位管理、产品选择与派遣。 +继承自 InfoHandler 和 LoginHandler,组合仓库 OCR 与角色选择能力。 +定义岛屿地图确认等待时间、产品选择安全区域及材料数量 OCR 区域等公共常量。 +""" from module.island.assets import * from module.ui.page import * from module.base.timer import Timer @@ -249,7 +255,7 @@ def _wait_island_map_entry(self, timeout=3): } def goto_island_map(self): - logger.hr("Island goto map", level=2) + logger.hr("岛屿-前往地图", level=2) expect_friend = bool(getattr(self, "_island_expect_friend", False)) status = self._wait_island_map_entry(timeout=10 if expect_friend else 3) in_map = status["in_map"] diff --git a/module/island/island_air_drop.py b/module/island/island_air_drop.py index e06121856..43b069dab 100644 --- a/module/island/island_air_drop.py +++ b/module/island/island_air_drop.py @@ -1,3 +1,8 @@ +"""岛屿空投任务模块。 + +处理岛屿每日空投领取逻辑,根据上次偷取时间判断是否执行空投流程。 +管理空投冷却时间(5小时)与每日重置边界,支持重复尝试与超时跳过。 +""" from module.island.island import * from time import sleep from module.ui.scroll import Scroll diff --git a/module/island/island_business.py b/module/island/island_business.py index 09665ca2d..7507c4cde 100644 --- a/module/island/island_business.py +++ b/module/island/island_business.py @@ -1,3 +1,8 @@ +"""岛屿生意经营模块。 + +管理岛屿商区的经营流程,包括经营剩余时间 OCR 读取、美食评审安全区域操作。 +结合季节性商品配置与定时刷新逻辑,自动化岛屿商区的日常运营。 +""" import os from module.island.island import Island from module.island.assets import * diff --git a/module/island/island_cargo_preparation.py b/module/island/island_cargo_preparation.py index 83a2d5b96..7bc15af3c 100644 --- a/module/island/island_cargo_preparation.py +++ b/module/island/island_cargo_preparation.py @@ -1,3 +1,8 @@ +"""岛屿货物筹备与运输模块。 + +处理岛屿货物筹备界面的检测、刷新与运输启动流程。 +包含运输状态识别(待处理/运行中)、运输时间 OCR、物品满意度模板匹配等核心功能。 +""" from datetime import timedelta from module.config.time_source import now as current_time @@ -238,7 +243,7 @@ def blacklist(self): return blacklist def run(self): - logger.hr('Island Cargo Preparation Run', level=1) + logger.hr('岛屿货物准备运行', level=1) self.ui_ensure(page_island) self.ui_goto(page_island_phone, get_ship=False) @@ -280,9 +285,9 @@ def _run_cargo_preparation(self): if self.transport_start(comm): comm.convert_to_running() - logger.hr('Cargo preparation status', level=2) + logger.hr('货物准备状态', level=2) for comm in commissions: - logger.attr('Cargo Preparation', comm) + logger.attr('货运委托', comm) return commissions @@ -293,7 +298,7 @@ def transport_refresh(self, comm): 更换页面默认选中第一个可替换委托,因此只需要确认列表不为空, 再点击确定按钮。 """ - logger.info('Cargo preparation replace commission') + logger.info('货物准备替换委托') self.interval_clear([TRANSPORT_REFRESH, CARGO_PREPARATION_REPLACE_CONFIRM]) if not self._open_replace_page(comm): @@ -327,11 +332,11 @@ def transport_slot_empty(self, index, offset): def _transport_detect(self): """从当前截图中检测所有货运委托。""" - logger.hr('Transport Commission detect') + logger.hr('运输委托检测') commissions = [] for index in range(3): comm = CargoPreparationTransport(main=self, index=index, blacklist=self.blacklist) - logger.attr('Transport Commission', comm) + logger.attr('运输委托', comm) for item in comm.items: logger.attr(item.button, item) commissions.append(comm) @@ -348,19 +353,19 @@ def transport_detect(self, trial=1, skip_first_screenshot=True): commissions = self._transport_detect() if not commissions.count: - logger.warning('No commission detected, retry commission detect') + logger.warning('[岛屿-货运] 未检测到委托,重试检测') continue if commissions.select(valid=False).count: - logger.warning('Found 1 invalid commission at least, retry commission detect') + logger.warning('[岛屿-货运] 检测到无效委托,重试检测') continue return commissions.select(valid=True) - logger.info('trials of transport commission detect exhausted, stop') + logger.info('[岛屿-货运] 委托检测重试耗尽,停止') return commissions.select(valid=True) def transport_receive(self): """领取运输页面上所有已完成的货运委托。""" - logger.hr('Island Transport', level=2) + logger.hr('岛屿运输', level=2) self.device.click_record_clear() self.interval_clear([GET_ITEMS_ISLAND, TRANSPORT_RECEIVE, POPUP_CANCEL_WHITE]) success = True @@ -408,7 +413,7 @@ def transport_receive(self): def transport_start(self, comm): """启动指定的货运委托。""" - logger.info('Transport commission start') + logger.info('[岛屿-货运] 运输委托开始') self.interval_clear([GET_ITEMS_ISLAND, TRANSPORT_START, POPUP_CANCEL_WHITE]) success = True confirm_timer = Timer(1, count=2).start() diff --git a/module/island/island_daily_gather.py b/module/island/island_daily_gather.py index 683c5afd0..213727fff 100644 --- a/module/island/island_daily_gather.py +++ b/module/island/island_daily_gather.py @@ -1,3 +1,8 @@ +"""岛屿每日采集模块。 + +管理岛屿资源采集的自动化流程,包括采集奖励领取、目标选择与角色派遣。 +根据角色体力阈值和工作状态进行智能选择,按生活等级排序优先派遣空闲角色。 +""" from module.island.island import * from datetime import timedelta diff --git a/module/island/island_daily_interact.py b/module/island/island_daily_interact.py index b6d54eb28..b2c7688e0 100644 --- a/module/island/island_daily_interact.py +++ b/module/island/island_daily_interact.py @@ -1,3 +1,8 @@ +"""岛屿每日互动任务模块。 + +处理岛屿低频互动任务的自动化执行,包括摸猫、JUU 速运、商区外送服务和每周照相。 +结合开发计划任务列表区域检测,按顺序执行所有已启用的互动任务。 +""" from datetime import timedelta from module.config.time_source import now as current_time @@ -21,7 +26,7 @@ class IslandDailyInteract(Island): def run(self): """执行启用的岛屿低频互动任务。""" - logger.hr('Island Daily Interact Run', level=1) + logger.hr('岛屿每日互动运行', level=1) self.ui_ensure(page_island) all_done = True @@ -48,7 +53,7 @@ def pet_cat(self): """ from module.island_daily_interact.assets import PET_CAT_FARM_INTERACT - logger.hr('Pet Cat', level=2) + logger.hr('撸猫', level=2) if not self.island_map_goto('farm'): logger.warning('[岛屿-每日周任务] 前往晨露农场失败,跳过摸猫任务') return False @@ -75,7 +80,7 @@ def juu_express(self): TEMPLATE_JUU_EXPRESS_TASK_ICON, ) - logger.hr('JUU Express', level=2) + logger.hr('JUU快递', level=2) if not self._detect_development_plan_template_task( task_template=TEMPLATE_JUU_EXPRESS_TASK_ICON, tab_button=DEVELOPMENT_PLAN_DAILY_TAB, @@ -119,7 +124,7 @@ def business_delivery(self): TEMPLATE_BUSINESS_DELIVERY_TASK_ICON, ) - logger.hr('Business Delivery', level=2) + logger.hr('商业配送', level=2) if not self._detect_development_plan_template_task( task_template=TEMPLATE_BUSINESS_DELIVERY_TASK_ICON, tab_button=DEVELOPMENT_PLAN_DAILY_TAB, @@ -165,7 +170,7 @@ def weekly_photo(self): WEEKLY_PHOTO_TASK_CHECK, ) - logger.hr('Weekly Photo', level=2) + logger.hr('每周拍照', level=2) completed = True while 1: if not self._start_development_plan_template_task( diff --git a/module/island/island_daily_order.py b/module/island/island_daily_order.py index 08227f02b..318ebe7e5 100644 --- a/module/island/island_daily_order.py +++ b/module/island/island_daily_order.py @@ -1,3 +1,8 @@ +"""岛屿每日订单模块。 + +处理岛屿每日订单的自动化交付,包括紧急委托检测、订单刷新与货物筹备状态判断。 +支持订单页面状态识别(为空/紧急/可交付/驳回)与左侧挑战图标的逐个处理。 +""" from module.island.island import Island import module.island_daily_order.assets as daily_order_assets from module.island_daily_order.assets import * @@ -60,7 +65,7 @@ class IslandDailyOrder(Island): _urgent_template_cache = None def run(self): - logger.hr('Island Daily Order Run', level=1) + logger.hr('岛屿每日订单', level=1) self.ui_ensure(page_island) diff --git a/module/island/island_farm.py b/module/island/island_farm.py index a086857c7..c7121a49f 100644 --- a/module/island/island_farm.py +++ b/module/island/island_farm.py @@ -1,3 +1,19 @@ +""" +岛屿农场(Island Farm)自动化管理模块。 + +负责岛屿系统中农场、果园、苗圃三大生产区域的自动化管理,包括: +- 仓库库存检查与低库存作物识别 +- 空闲岗位检测与自动播种 +- 作物选择策略:优先补种低于阈值的作物,其次种植默认作物 +- 工人派遣与角色筛选(含小天城橡胶树优先机制) +- 季节限定作物的智能过滤 +- 种子不足时自动从商店购买 + +管理的生产区域: +- 农场(farm):小麦、玉米、水稻、白菜、土豆、大豆、牧草、咖啡豆 +- 果园(orchard):苹果、柑橘、香蕉、芒果、柠檬、牛油果、橡胶 +- 苗圃(nursery):胡萝卜、洋葱、亚麻、草莓、棉花、茶叶、薰衣草、菠萝、芦笋 +""" from module.island_farm.assets import * from module.island.island import * from datetime import timedelta @@ -8,6 +24,36 @@ class IslandFarm(Island, WarehouseOCR, LoginHandler): + """ + 岛屿农场自动化管理器。 + + 继承 Island(岛屿基础操作)、WarehouseOCR(仓库 OCR 识别)和 + LoginHandler(登录处理),实现农场、果园、苗圃的全自动管理。 + + 执行流程: + 1. 进入岛屿并检查仓库库存,识别低库存作物 + 2. 进入岗位管理页面,遍历所有岗位检测状态 + 3. 对空闲岗位执行播种(优先补种低库存作物,其次默认作物) + 4. 计算下次运行时间(取所有岗位完成时间和 6 小时后的最小值) + + Attributes: + season_config: 季节配置对象,用于季节限定作物过滤。 + farm_positions (int): 农场岗位数量。 + orchard_positions (int): 果园岗位数量。 + nursery_positions (int): 苗圃岗位数量。 + farm_threshold (int): 农场作物库存最低阈值。 + orchard_threshold (int): 果园作物库存最低阈值。 + nursery_threshold (int): 苗圃作物库存最低阈值。 + worker_filters (dict): 各区域的工人派遣角色筛选器。 + ignore_avocado (bool): 是否忽略牛油果。 + ignore_pineapple (bool): 是否忽略菠萝。 + plant_config (dict): 各区域的默认作物种植配置。 + INVENTORY_CONFIG (dict): 各区域的仓库物品配置(模板、选择按钮、种子数量等)。 + posts (dict): 岗位信息字典,包含按钮和当前种植作物。 + to_plant_lists (dict): 各区域需要补种的作物列表。 + name_to_config (dict): 作物名称到配置项的映射。 + inventory_counts (dict): 各区域的仓库库存统计。 + """ def __init__(self, *args, **kwargs): Island.__init__(self, *args, **kwargs) WarehouseOCR.__init__(self) @@ -237,7 +283,17 @@ def _is_nursery_crop_in_season(self, crop_name): return True def warehouse_inventory(self, category): - """获取仓库库存信息""" + """ + 获取指定区域的仓库库存信息。 + + 通过 OCR 识别仓库中各作物的数量,同时设置实例属性方便后续访问。 + + Args: + category (str): 区域类型,'farm'、'orchard' 或 'nursery'。 + + Returns: + dict: 作物名称到数量的映射,如 {'wheat': 50, 'corn': 30}。 + """ config = self.INVENTORY_CONFIG[category] self.warehouse_filter(config['filter']) image = self.device.screenshot() @@ -250,6 +306,17 @@ def warehouse_inventory(self, category): return results def post_plant_check(self, category): + """ + 检查当前岗位正在种植的作物类型。 + + 通过模板匹配检测岗位详情页面中的作物图标,确定正在种植的作物。 + + Args: + category (str): 区域类型,'farm'、'orchard' 或 'nursery'。 + + Returns: + str 或 None: 正在种植的作物名称,未检测到则返回 None。 + """ config = self.INVENTORY_CONFIG[category] for item in config['items']: if self.appear(item['post_action']): @@ -257,6 +324,20 @@ def post_plant_check(self, category): return None def decided_lists(self, post_button, post_id, category, time_var_name): + """ + 检查指定岗位的状态并更新相关列表。 + + 打开岗位详情,判断岗位是已完成、正在工作还是空闲: + - 已完成:清除作物信息和时间变量 + - 正在工作:记录作物类型、读取剩余完成时间,从补种列表中移除 + - 空闲:清除作物信息和时间变量 + + Args: + post_button (Button): 岗位按钮资源。 + post_id (str): 岗位标识,如 'ISLAND_FARM_POST1'。 + category (str): 区域类型,'farm'、'orchard' 或 'nursery'。 + time_var_name (str): 对应的时间变量名,用于存储完成时间。 + """ self.post_close() self.post_open(post_button) self.device.screenshot() @@ -297,6 +378,21 @@ def get_orchard_character_filter(self, product): return characters def post_plant(self, post_button, product, category, time_var_name): + """ + 在指定岗位上执行播种操作。 + + 完整流程:打开岗位 -> 选择产品 -> 选择角色派遣 -> 确认订单 -> 记录完成时间。 + 种子不足时自动从商店购买补充。 + + Args: + post_button (Button): 岗位按钮资源。 + product (str): 要种植的作物名称,如 'wheat'、'apple'。 + category (str): 区域类型,'farm'、'orchard' 或 'nursery'。 + time_var_name (str): 对应的时间变量名,用于存储完成时间。 + + Returns: + bool: 播种是否成功。 + """ self.post_close() self.post_open(post_button) self.device.screenshot() diff --git a/module/island/island_fishery.py b/module/island/island_fishery.py index 66ca40d5c..5fb789ebc 100644 --- a/module/island/island_fishery.py +++ b/module/island/island_fishery.py @@ -1,3 +1,8 @@ +"""岛屿渔业模块。 + +管理岛屿渔业系统的自动化操作,包括鱼苗商店的分类浏览(淡水/海水/其他)与采购。 +支持渔场库存检测、鱼苗购买与鱼获处理,与仓库 OCR 联动获取物品数量。 +""" from module.island_fishery.assets import * from module.island_rancher.assets import * from module.island.island import * diff --git a/module/island/island_grill.py b/module/island/island_grill.py index 5de5c1eab..ee33e63a1 100644 --- a/module/island/island_grill.py +++ b/module/island/island_grill.py @@ -1,3 +1,8 @@ +"""岛屿烧烤店铺模块。 + +继承 IslandShopBase,实现烧烤店的商品配置与岗位管理。 +包含烤串、土豆鸡、蛋卷胡萝卜、炒鸡排、牛排碗等菜品的模板、选择与后续操作定义。 +""" from module.island_grill.assets import * from module.island.island_shop_base import IslandShopBase from module.logger import logger @@ -77,7 +82,7 @@ def test(self): self.island_error = False self.post_get_and_close() if self.island_error: - logger.info('error1') + logger.info('[岛屿-烧烤] 错误1') if __name__ == "__main__": diff --git a/module/island/island_juu_coffee.py b/module/island/island_juu_coffee.py index fc450f569..b94ad78c1 100644 --- a/module/island/island_juu_coffee.py +++ b/module/island/island_juu_coffee.py @@ -1,3 +1,8 @@ +"""岛屿 JUU 咖啡店模块。 + +继承 IslandShopBase,配置咖啡店的商品列表与岗位参数。 +包含冰咖啡、蛋卷、拿铁、柑橘咖啡等饮品的模板、选择与制作流程定义。 +""" from module.island_juu_coffee.assets import * from module.island.island_shop_base import IslandShopBase from module.island.assets import * diff --git a/module/island/island_juu_eatery.py b/module/island/island_juu_eatery.py index aac15bfb5..697a74c83 100644 --- a/module/island/island_juu_eatery.py +++ b/module/island/island_juu_eatery.py @@ -1,3 +1,8 @@ +"""岛屿 JUU 餐厅模块。 + +继承 IslandShopBase,配置餐厅的商品列表与岗位参数。 +包含苹果派、玉米杯、橙子派、香蕉可丽饼等餐品的模板、选择与制作流程定义。 +""" from module.island_juu_eatery.assets import * from module.island.island_shop_base import IslandShopBase from module.island.assets import * diff --git a/module/island/island_manufacture.py b/module/island/island_manufacture.py index 594a4df6f..346b105d7 100644 --- a/module/island/island_manufacture.py +++ b/module/island/island_manufacture.py @@ -1,3 +1,8 @@ +"""岛屿制造工坊模块。 + +继承 IslandShopBase,实现制造工坊的产品配置与岗位管理。 +支持固定位置按钮(如荠菜)、滑动配置及制造时间前缀设置,管理工坊自动化生产流程。 +""" from module.island.island import * from module.island_manufacture.assets import * from module.island.island_shop_base import IslandShopBase diff --git a/module/island/island_mine_forest.py b/module/island/island_mine_forest.py index 72035e81f..5c9dd3aaf 100644 --- a/module/island/island_mine_forest.py +++ b/module/island/island_mine_forest.py @@ -1,3 +1,8 @@ +"""岛屿矿山与林场模块。 + +管理矿山和林场的自动化资源采集,包括铜、铝、铁、硫、银等矿产及林木资源。 +配置工人筛选与库存管理,支持登录重连与仓库 OCR 数量检测。 +""" from module.island.island import * from module.island_mine_forest.assets import * from module.ui.page import * diff --git a/module/island/island_pearl_sell.py b/module/island/island_pearl_sell.py index b308dd0a7..518083e52 100644 --- a/module/island/island_pearl_sell.py +++ b/module/island/island_pearl_sell.py @@ -1,3 +1,8 @@ +"""岛屿珍珠售卖模块。 + +管理每周珍珠的采购与售卖流程,根据交易周期和每日刷新时间自动执行。 +支持价格 OCR 识别与重试机制,处理岛屿珍珠交易的定时调度逻辑。 +""" import re from datetime import timedelta @@ -48,7 +53,7 @@ class IslandPearlSell(Island): PHASE_DELAYED = "delayed" def run(self): - logger.hr("Island Pearl Sell Run", level=1) + logger.hr("岛屿珍珠出售运行", level=1) now = current_time().replace(microsecond=0) pearl_trade_time = self._get_next_pearl_trade_time(now=now) @@ -56,10 +61,10 @@ def run(self): # 判断当前触发类型 trade_due = self._trade_due(now=now, next_time=pearl_trade_time) refresh_due = False if trade_due else self._refresh_due(now=now) - logger.attr("SchedulerNextRun", self.config.Scheduler_NextRun) - logger.attr("PearlTradeTime", pearl_trade_time) - logger.attr("PearlTradeDue", trade_due) - logger.attr("PearlRefreshDue", refresh_due) + logger.attr("下次调度运行", self.config.Scheduler_NextRun) + logger.attr("珍珠交易时间", pearl_trade_time) + logger.attr("珍珠交易到期", trade_due) + logger.attr("珍珠刷新到期", refresh_due) if not trade_due and not refresh_due: target = self._next_run(now=now) @@ -107,7 +112,7 @@ def run(self): def run_buy_phase(self): """执行采购阶段。""" - logger.hr("Pearl buy phase", level=2) + logger.hr("珍珠购买阶段", level=2) self._purchase_quota_exhausted = False # 检查购买延时——如果还没到购买时间则跳过采购,让售卖正常执行 @@ -214,7 +219,7 @@ def run_buy_phase_once(self): def run_sell_phase(self): """执行售卖阶段。""" - logger.hr("Pearl sell phase", level=2) + logger.hr("珍珠出售阶段", level=2) sell_price_limit = int(self.config.IslandPearlSell_SellPrice) if not self._enter_home_pearl_shop("assembly"): @@ -788,7 +793,7 @@ def _action_name(action): def run_price_refresh(self): """每日 03:00 进入珍珠售卖商店后立即退出,刷新价格显示。""" - logger.hr("Pearl price refresh", level=2) + logger.hr("珍珠价格刷新", level=2) if not self._enter_home_pearl_shop("assembly"): logger.warning("[岛屿-珍珠采购] 价格刷新:进入珍珠商店失败") return False diff --git a/module/island/island_rancher.py b/module/island/island_rancher.py index 81fdae970..bc8f4ab5d 100644 --- a/module/island/island_rancher.py +++ b/module/island/island_rancher.py @@ -1,3 +1,8 @@ +"""岛屿牧场模块。 + +管理岛屿牧场的自动化运营,包括农场作物(小麦、玉米、牧场)、磨坊加工与畜牧养殖。 +通过仓库 OCR 检测库存数量,根据阈值配置自动补充鸡肉和猪肉等畜牧产品。 +""" from module.island_farm.assets import * from module.island_rancher.assets import * from module.island.island import * diff --git a/module/island/island_restaurant.py b/module/island/island_restaurant.py index 0b55791ca..a6b2008c2 100644 --- a/module/island/island_restaurant.py +++ b/module/island/island_restaurant.py @@ -1,3 +1,8 @@ +"""岛屿餐厅模块。 + +继承 IslandShopBase,实现餐厅的菜品配置、季节性菜品管理与岗位运营。 +包含凉拌双笋、芦笋炒虾仁等时令菜品定义,支持固定位置按钮与菜品种类统计。 +""" from module.island_restaurant.assets import * from module.island.island_shop_base import IslandShopBase from module.island.assets import * diff --git a/module/island/island_select_character.py b/module/island/island_select_character.py index 41923a49f..47eec3594 100644 --- a/module/island/island_select_character.py +++ b/module/island/island_select_character.py @@ -1,3 +1,8 @@ +"""岛屿角色选择模块。 + +提供岛屿岗位派遣时的角色选择功能,基于角色网格布局进行状态检测与智能筛选。 +通过颜色识别判断角色工作状态、体力值与选中状态,支持 OCR 读取体力数值。 +""" from module.island_select_character.assets import * from module.base.button import * from module.base.utils import color_similar, color_similarity_2d, crop, get_color diff --git a/module/island/island_shop_base.py b/module/island/island_shop_base.py index 4485ccdee..ae8264b18 100644 --- a/module/island/island_shop_base.py +++ b/module/island/island_shop_base.py @@ -1,3 +1,8 @@ +"""岛屿店铺基类模块。 + +为烧烤店、茶馆、餐厅、制造工坊等所有岛屿店铺提供通用的岗位管理与商品生产框架。 +包含产品选择、厨师筛选、岗位填充循环、季节性商品处理等核心逻辑,是所有店铺模块的父类。 +""" from module.island.island import * from collections import Counter from datetime import timedelta diff --git a/module/island/island_teahouse.py b/module/island/island_teahouse.py index ac7a6e443..19c9089a0 100644 --- a/module/island/island_teahouse.py +++ b/module/island/island_teahouse.py @@ -1,3 +1,8 @@ +"""岛屿茶馆模块。 + +继承 IslandShopBase,配置茶馆的商品列表与岗位参数。 +包含迎春花茶等固定位置饮品定义,支持季节性菜品、时长 OCR 与重试滑动机制。 +""" from module.island_teahouse.assets import * from module.island.island_shop_base import IslandShopBase from module.island.assets import * diff --git a/module/island/ui.py b/module/island/ui.py index 7338fa906..d2aec3924 100644 --- a/module/island/ui.py +++ b/module/island/ui.py @@ -1,3 +1,14 @@ +""" +岛屿(Island)UI 导航模块。 + +提供岛屿系统中各子页面的导航和检测功能,包括: +- 岛屿管理页面的进入与检测 +- 岛屿运输页面的进入与检测 +- 季节活动底部导航栏的切换 +- 从岛屿子页面返回手机页面 +- 确保当前在指定页面的导航逻辑 +- 处理岛屿相关的弹窗(维护公告、信息弹窗等) +""" from module.base.timer import Timer from module.handler.assets import MAINTENANCE_ANNOUNCE, USE_DATA_KEY_NOTIFIED from module.island.assets import * @@ -8,7 +19,28 @@ class IslandUI(UI): + """ + 岛屿 UI 导航处理器。 + + 继承 UI 基类,提供岛屿系统特有的页面导航和弹窗处理功能。 + 作为岛屿各功能模块(农场、牧场、渔场等)的 UI 基础设施层。 + + 主要功能: + - 页面检测:island_in_management()、island_in_transport() + - 页面导航:island_management_enter()、island_transport_enter()、island_ui_back() + - 页面确保:ui_ensure_management_page()(带自动返回逻辑) + - 弹窗处理:handle_get_items()、ui_additional()(维护公告、信息弹窗) + + Attributes: + 继承自 UI 的所有属性(config、device、image 等)。 + """ def ui_additional(self, get_ship=True): + """ + 处理岛屿页面的额外弹窗,覆盖父类方法禁用舰船获取处理。 + + Args: + get_ship (bool): 是否处理舰船获取弹窗(在岛屿中固定为 False)。 + """ return super().ui_additional(get_ship=False) def island_in_management(self, interval=0): @@ -26,8 +58,13 @@ def island_in_management(self, interval=0): #@cached_property def _island_season_bottom_navbar(self): """ - 季节活动底部导航栏,包含 6 个选项: - 主页、PT奖励、赛季任务、赛季商店、赛季排名、赛季历史 + 创建季节活动底部导航栏实例。 + + 导航栏包含 6 个选项卡:主页、PT奖励、赛季任务、赛季商店、赛季排名、赛季历史。 + 通过活跃颜色 (237, 237, 237) 和非活跃颜色 (65, 78, 96) 区分选中状态。 + + Returns: + Navbar: 季节活动底部导航栏实例。 """ island_season_bottom_navbar = ButtonGrid( origin=(14, 677), delta=(213, 0), @@ -75,7 +112,7 @@ def island_management_enter(self): in: page_island_phone out: ISLAND_MANAGEMENT_CHECK """ - logger.info('Island management enter') + logger.info('进入岛屿管理') self.interval_clear(ISLAND_MANAGEMENT_CHECK) if self.appear(ISLAND_MANAGEMENT_LOCKED, offset=(20, 20)): return False @@ -99,7 +136,7 @@ def island_transport_enter(self): in: page_island_phone out: ISLAND_TRANSPORT_CHECK """ - logger.info('Island transport enter') + logger.info('进入岛屿运输') self.ui_click( click_button=ISLAND_TRANSPORT, check_button=self.island_in_transport, @@ -117,7 +154,7 @@ def island_ui_back(self): in: 任意带有 SHOP_BACK_ARROW 的页面 out: page_island_phone """ - logger.info('Island UI back') + logger.info('岛屿UI返回') self.ui_click( click_button=SHOP_BACK_ARROW, check_button=page_island_phone.check_button, @@ -134,7 +171,7 @@ def ui_ensure_management_page(self): in: page_island_phone 或产品页面 out: ISLAND_MANAGEMENT_CHECK """ - logger.info('UI ensure management page') + logger.info('UI确保管理页面') self.interval_clear(ISLAND_MANAGEMENT_CHECK) confirm_timer = Timer(1, count=2).start() for _ in self.loop(): @@ -152,6 +189,14 @@ def ui_ensure_management_page(self): continue def handle_get_items(self): + """ + 处理岛屿中的物品获取弹窗。 + + 检测 GET_ITEMS_ISLAND 弹窗并点击关闭。 + + Returns: + bool: 是否检测到并处理了物品获取弹窗。 + """ if self.appear_then_click(GET_ITEMS_ISLAND, offset=(20, 20), interval=2): return True return False diff --git a/module/island/warehouse.py b/module/island/warehouse.py index c68a9bfb9..5e707fb6f 100644 --- a/module/island/warehouse.py +++ b/module/island/warehouse.py @@ -1,3 +1,8 @@ +"""岛屿仓库 OCR 模块。 + +提供岛屿仓库物品数量的 OCR 识别功能,基于网格布局遍历仓库槽位。 +通过模板匹配定位目标物品,再对数量区域进行数字 OCR 读取,返回库存数量。 +""" from module.ocr.ocr import * from module.base.button import * from module.ui.ui import * diff --git a/module/island_business/assets.py b/module/island_business/assets.py index 8e4e7ddbc..6dd1d59d8 100644 --- a/module/island_business/assets.py +++ b/module/island_business/assets.py @@ -1,3 +1,5 @@ +"""岛屿生意模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_cargo_preparation/assets.py b/module/island_cargo_preparation/assets.py index 8525f61f5..879653369 100644 --- a/module/island_cargo_preparation/assets.py +++ b/module/island_cargo_preparation/assets.py @@ -1,3 +1,5 @@ +"""岛屿货物准备模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_daily_interact/__init__.py b/module/island_daily_interact/__init__.py index 8b1378917..58efdaba8 100644 --- a/module/island_daily_interact/__init__.py +++ b/module/island_daily_interact/__init__.py @@ -1 +1 @@ - +"""岛屿每日互动模块。""" diff --git a/module/island_daily_interact/assets.py b/module/island_daily_interact/assets.py index 17a469e35..cf50a4f6d 100644 --- a/module/island_daily_interact/assets.py +++ b/module/island_daily_interact/assets.py @@ -1,3 +1,5 @@ +"""岛屿每日互动模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_daily_order/assets.py b/module/island_daily_order/assets.py index 3fd71dc99..16f641fc5 100644 --- a/module/island_daily_order/assets.py +++ b/module/island_daily_order/assets.py @@ -1,3 +1,5 @@ +"""岛屿每日订单模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_farm/assets.py b/module/island_farm/assets.py index 85015c5ae..83b2383fb 100644 --- a/module/island_farm/assets.py +++ b/module/island_farm/assets.py @@ -1,3 +1,5 @@ +"""岛屿农场模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_fishery/__init__.py b/module/island_fishery/__init__.py index e69de29bb..da39ecc0f 100644 --- a/module/island_fishery/__init__.py +++ b/module/island_fishery/__init__.py @@ -0,0 +1 @@ +"""岛屿渔业模块。""" diff --git a/module/island_fishery/assets.py b/module/island_fishery/assets.py index 2636756d1..c5d66a9f2 100644 --- a/module/island_fishery/assets.py +++ b/module/island_fishery/assets.py @@ -1,3 +1,5 @@ +"""岛屿渔业模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_grill/assets.py b/module/island_grill/assets.py index 08185a6c9..854b93146 100644 --- a/module/island_grill/assets.py +++ b/module/island_grill/assets.py @@ -1,3 +1,5 @@ +"""岛屿烧烤模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_juu_coffee/assets.py b/module/island_juu_coffee/assets.py index dac0bd35e..193365583 100644 --- a/module/island_juu_coffee/assets.py +++ b/module/island_juu_coffee/assets.py @@ -1,3 +1,5 @@ +"""岛屿咖啡模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_juu_eatery/assets.py b/module/island_juu_eatery/assets.py index ff46c9273..64016d3d1 100644 --- a/module/island_juu_eatery/assets.py +++ b/module/island_juu_eatery/assets.py @@ -1,3 +1,5 @@ +"""岛屿餐厅模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_manufacture/assets.py b/module/island_manufacture/assets.py index 173fac165..a9704e913 100644 --- a/module/island_manufacture/assets.py +++ b/module/island_manufacture/assets.py @@ -1,3 +1,5 @@ +"""岛屿制造模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_mine_forest/assets.py b/module/island_mine_forest/assets.py index 758539726..34afb0200 100644 --- a/module/island_mine_forest/assets.py +++ b/module/island_mine_forest/assets.py @@ -1,3 +1,5 @@ +"""岛屿矿林模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_pearl_sell/assets.py b/module/island_pearl_sell/assets.py index 4791926fc..b406451d3 100644 --- a/module/island_pearl_sell/assets.py +++ b/module/island_pearl_sell/assets.py @@ -1,3 +1,5 @@ +"""岛屿珍珠模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_rancher/assets.py b/module/island_rancher/assets.py index 9f213fb7b..62f73a041 100644 --- a/module/island_rancher/assets.py +++ b/module/island_rancher/assets.py @@ -1,3 +1,5 @@ +"""岛屿牧场模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_restaurant/assets.py b/module/island_restaurant/assets.py index 1b7c2248a..f955917ec 100644 --- a/module/island_restaurant/assets.py +++ b/module/island_restaurant/assets.py @@ -1,3 +1,5 @@ +"""岛屿饭店模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_select_character/assets.py b/module/island_select_character/assets.py index c6ab8e4e9..ed68071db 100644 --- a/module/island_select_character/assets.py +++ b/module/island_select_character/assets.py @@ -1,3 +1,5 @@ +"""岛屿角色选择模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/island_teahouse/assets.py b/module/island_teahouse/assets.py index 4a436e323..6b3c9d438 100644 --- a/module/island_teahouse/assets.py +++ b/module/island_teahouse/assets.py @@ -1,3 +1,5 @@ +"""岛屿茶馆模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/llm.py b/module/llm.py index c8b47bac8..910f398fd 100644 --- a/module/llm.py +++ b/module/llm.py @@ -1,9 +1,28 @@ +"""LLM 错误分析模块。 + +使用 OpenAI 兼容 API 对脚本运行中捕获的异常进行智能分析。 +当配置启用 LLM 错误分析时,会将异常堆栈发送给 LLM, +获取错误原因分析和修复建议,帮助用户快速定位问题。 + +配置项: +- Error_LlmAnalysis: 是否启用 LLM 错误分析 +- Error_LlmApiKey: OpenAI API Key +- Error_LlmApiBase: API 基础 URL(默认 https://api.openai.com/v1) +- Error_LlmModel: 使用的模型名称(默认 gpt-4o-mini) + +特性: +- 相同错误的分析结果会缓存,避免重复调用 API +- 使用 MD5 哈希进行错误去重 +- API 调用失败时静默降级,不影响正常运行 +""" + import traceback import os from module.logger import logger import hashlib +# 已分析错误的缓存,键为堆栈信息的 MD5 哈希 _analyzed_errors_cache = {} LLM_CONFIG_WARNING = 'LLM 错误分析不可用,请检查 LLM 配置、API Key、API Base、模型名称以及账户余额。' LLM_EMPTY_RESULT_WARNING = 'LLM API 返回了空结果,请检查模型服务配置、模型名称或账户余额。' diff --git a/module/log_res/__init__.py b/module/log_res/__init__.py index d9252edde..c09041ac0 100644 --- a/module/log_res/__init__.py +++ b/module/log_res/__init__.py @@ -1 +1,3 @@ +"""日志资源模块。""" + from .log_res import LogRes diff --git a/module/log_res/log_res.py b/module/log_res/log_res.py index 54d24372a..d368b383d 100644 --- a/module/log_res/log_res.py +++ b/module/log_res/log_res.py @@ -1,3 +1,21 @@ +"""资源变动记录与同步模块。 + +当各项游戏资源数值(如石油、物资、钻石、魔方等)发生变化时, +负责更新配置文件中对应的 Dashboard 项及记录时间戳。 + +使用示例: + >>> log_res = LogRes(config) + >>> log_res.Oil = 12345 # 自动更新 Dashboard.Oil.Value 和记录时间 + >>> log_res.Coin = {'Value': 50000, 'Limit': 99999} # 同时记录值和上限 + +Dashboard 数据结构: + Dashboard.<资源名> = { + 'Value': int, # 当前值 + 'Record': datetime, # 最后更新时间 + 'Limit': int, # 上限(可选) + } +""" + # 此文件实现了资源变动的记录与同步功能。 # 当各项资源数值(如石油、魔方等)发生变化时,负责更新配置文件中对应的 Dashboard 项及记录时间戳。 from cached_property import cached_property @@ -35,7 +53,7 @@ def __setattr__(self, key, value): instance_name = getattr(self.config, 'config_name', 'default') cl1_db.async_add_yellow_coin_snapshot(instance_name, int(value), source='dashboard') except Exception: - logger.exception('Failed to save yellow coin snapshot') + logger.exception('[日志资源] 保存金币快照失败') # 记录全量资源快照 self._record_all_resource_snapshot({key: value}) elif isinstance(value, dict): @@ -72,7 +90,7 @@ def __setattr__(self, key, value): else: self._record_all_resource_snapshot() else: - logger.info('No such resource on dashboard') + logger.info('[日志资源] 仪表盘中无此资源') super().__setattr__(name=key, value=value) def _record_all_resource_snapshot(self, overrides=None): @@ -99,7 +117,7 @@ def _record_all_resource_snapshot(self, overrides=None): pass record_resource_snapshot(instance_name, resources) except Exception: - logger.exception('Failed to record resource snapshot') + logger.exception('[日志资源] 记录资源快照失败') def group(self, name): return deep_get(self.config.data, f'Dashboard.{name}') @@ -127,7 +145,7 @@ def log_res(self, name, modified: dict, update=True): if update: self.config.update() else: - logger.warning('No such resource!') + logger.warning('[日志资源] 没有此资源!') return True """ if __name__ == '__main__': diff --git a/module/logger.py b/module/logger.py index 7bc17e1fd..ff48bdfcd 100644 --- a/module/logger.py +++ b/module/logger.py @@ -1,3 +1,24 @@ +"""AzurPilot 日志(Logger)系统模块。 + +基于 Rich 库构建的多目标日志系统,支持控制台彩色输出、文件轮转记录 +和 WebUI 流式渲染。全局 logger 实例(alas)被整个框架共享使用。 + +主要组件: + - RichFileHandler: 文件日志处理器(基于 Rich 格式化)。 + - RichRenderableHandler: 将日志渲染为可渲染对象传递给回调,用于 WebUI 实时展示。 + - RichTimedRotatingHandler: 按时间轮转的文件日志处理器,支持跨平台多进程。 + - HTMLConsole: 输出 HTML 格式的 Rich Console,用于 WebUI 渲染。 + - Highlighter: 自定义正则高亮器,高亮路径、URL、Python 布尔值/None 等。 + +提供的辅助函数: + - hr(): 分节标题输出(支持 4 级标题)。 + - attr() / attr_align(): 属性对齐输出。 + - error_context() / exception_context(): 结构化错误信息输出。 + +全局 logger 实例通过 monkey-patch 方式扩展了上述方法,作为整个框架的 +统一日志入口。 +""" + import datetime import io import json @@ -552,7 +573,7 @@ def show(): logger.hr('hr1', 1) logger.hr('hr2', 2) logger.hr('hr3', 3) - logger.info(r'Brace { [ ( ) ] }') + logger.info(r'大括号 { [ ( ) ] }') logger.info(r'True, False, None') logger.info(r'E:/path\\to/alas/alas.exe, /root/alas/, ./relative/path/log.txt') local_var1 = 'This is local variable' @@ -603,4 +624,4 @@ def exception_context(title, exc, impact, action, level=logging.ERROR): logger.log_file: str logger.set_file_logger() -logger.hr('Start', level=0) +logger.hr('启动', level=0) diff --git a/module/map/assets.py b/module/map/assets.py index 81f2e01e0..730c0d3c4 100644 --- a/module/map/assets.py +++ b/module/map/assets.py @@ -1,3 +1,5 @@ +"""地图模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/map/camera.py b/module/map/camera.py index 89e17a73b..4763136ac 100644 --- a/module/map/camera.py +++ b/module/map/camera.py @@ -1,3 +1,21 @@ +"""地图相机控制系统。 + +管理地图探索时的相机移动和视图更新。碧蓝航线的地图是可滚动的网格系统, +相机用于聚焦到特定区域进行侦察和操作。 + +核心功能: +- 地图滑动:通过滑动向量控制相机在地图上移动 +- 视图更新:通过透视检测(Perspective Detection)解析当前视野中的网格信息 +- 坐标转换:全局坐标(map 坐标)与局部坐标(view 坐标)的相互转换 +- 全图扫描:系统性地扫描整个地图,发现所有敌人和事件 +- 错误恢复:处理各种检测错误(信息栏遮挡、弹窗、剧情等) + +坐标系统: +- 全局坐标 (camera):地图上的绝对位置,如 (3, 5) 表示第3列第5行 +- 局部坐标 (view):当前视野中的相对位置 +- center_offset:相机中心相对于网格中心的偏移量,(0.5, 0.5) 表示完美居中 +""" + import copy import numpy as np @@ -22,6 +40,19 @@ class Camera(MapOperation): + """地图相机控制器。 + + 管理地图探索中的相机位置、视图更新和坐标转换。 + 通过透视检测将屏幕截图解析为网格信息,并提供全局/局部坐标转换。 + + Attributes: + view (View): 当前视野的网格视图对象。 + map (CampaignMap): 战役地图对象,存储全局地图数据。 + camera (tuple[int, int]): 当前相机位置(全局坐标)。 + grid_class (Grid): 网格检测器类,默认为 Grid。 + _prev_view (View | None): 上一次滑动前的视图快照,用于预测滑动结果。 + _prev_swipe (np.ndarray | None): 上一次的滑动向量。 + """ view: View map: CampaignMap camera = (0, 0) @@ -76,7 +107,7 @@ def map_swipe(self, vector): Returns: bool: 相机是否移动了。 """ - logger.info('Map swipe: %s' % str(vector)) + logger.info('[地图-摄像机] 地图滑动: %s' % str(vector)) self._prev_view = copy.copy(self.view) self._prev_swipe = vector vector = np.array(vector) @@ -95,7 +126,7 @@ def focus_to_grid_center(self, tolerance=None): if not tolerance: tolerance = self.config.MAP_GRID_CENTER_TOLERANCE if np.any(np.abs(self.view.center_offset - 0.5) > tolerance): - logger.info('Re-focus to grid center.') + logger.info('[地图-摄像机] 重新聚焦到格子中心') return self.map_swipe((0, 0)) return False @@ -113,81 +144,81 @@ def _update_view(self): and not self.is_in_strategy_submarine_move() \ and not self.is_in_strategy_mob_move() \ and not self.is_in_strategy_air_strike(): - logger.warning('Image to detect is not in_map') + logger.warning('[地图-摄像机] 待检测图像不在地图中') raise MapDetectionError('Image to detect is not in_map') self.view.load(self.device.image) except MapDetectionError as e: if self.info_bar_count(): - logger.warning('Perspective error caused by info bar') + logger.warning('[地图-摄像机] 信息栏导致透视错误') self.handle_info_bar() return False elif self.appear(GET_ITEMS_1, offset=5): - logger.warning('Perspective error caused by get_items') + logger.warning('[地图-摄像机] 获取物品导致透视错误') # 此处不要使用 handle_mystery(),因为大世界会覆盖它。 self.device.click(GET_ITEMS_1) return False elif self.appear(GET_ITEMS_1_RYZA, offset=(-20, -100, 20, 20)): - logger.warning('Perspective error caused by GET_ITEMS_1_RYZA') + logger.warning('[地图-摄像机] GET_ITEMS_1_RYZA导致透视错误') self.device.click(GET_ITEMS_1_RYZA) return False elif self.appear(GET_ADAPTABILITY, offset=(20, 20)): - logger.warning('Perspective error caused by GET_ADAPTABILITY') + logger.warning('[地图-摄像机] 适应性弹窗导致透视错误') self.device.click(GET_ADAPTABILITY) return False elif self.handle_story_skip(): - logger.warning('Perspective error caused by story') + logger.warning('[地图-摄像机] 剧情导致透视错误') self.ensure_no_story(skip_first_screenshot=False) return False elif self.appear(GET_MISSION, offset=(20, 20)): - logger.warning('Perspective error caused by GET_MISSION') + logger.warning('[地图-摄像机] 任务弹窗导致透视错误') self.device.click(GET_MISSION) return False elif self.is_in_stage(): - logger.warning('Image is in stage') + logger.warning('[地图-摄像机] 图像在关卡选择界面') raise CampaignEnd('Image is in stage') elif self.appear(MAP_PREPARATION, offset=(20, 20)): - logger.warning('Image is in MAP_PREPARATION') + logger.warning('[地图-摄像机] 图像在地图准备界面') self.enter_map_cancel() raise CampaignEnd('Image is in MAP_PREPARATION') elif self.appear(AUTO_SEARCH_MENU_CONTINUE, offset=self._auto_search_menu_offset): - logger.warning('Image is in auto search menu') + logger.warning('[地图-摄像机] 图像在自动搜索菜单') self.ensure_auto_search_exit() raise CampaignEnd('Image is in auto search menu') elif self.appear(GLOBE_GOTO_MAP, offset=(20, 20)): - logger.warning('Image is in OS globe map') + logger.warning('[地图-摄像机] 图像在大世界地球仪地图') self.ui_click(GLOBE_GOTO_MAP, check_button=self.is_in_map, offset=(20, 20), retry_wait=3, skip_first_screenshot=True) return False elif self.appear(AUTO_SEARCH_REWARD, offset=(50, 50)): - logger.warning('Perspective error caused by AUTO_SEARCH_REWARD') + logger.warning('[地图-摄像机] 自动搜索奖励导致透视错误') if hasattr(self, 'os_auto_search_quit'): self.os_auto_search_quit() return False else: - logger.warning('Cannot find method os_auto_search_quit(), use ui_click() instead') + logger.warning('[地图-摄像机] 未找到os_auto_search_quit()方法,使用ui_click()代替') self.ui_click(AUTO_SEARCH_REWARD, check_button=self.is_in_map, offset=(50, 50), retry_wait=3, skip_first_screenshot=True) return False elif self.appear(OPSI_MISSION_CHECK, offset=(20, 20)): - logger.warning('Perspective error caused by OPSI_MISSION_CHECK') + logger.warning('[地图-摄像机] 大世界任务检查导致透视错误') if hasattr(self, 'os_mission_quit'): self.os_mission_quit() return False else: - logger.warning('Cannot find method os_mission_quit(), use ui_click() instead') + logger.warning('[地图-摄像机] 未找到os_mission_quit()方法,使用ui_click()代替') self.ui_click(OPSI_MISSION_CHECK, check_button=self.is_in_map, offset=(200, 5), skip_first_screenshot=True) return False elif 'opsi' in self.config.task.command.lower() and self.handle_popup_confirm('OPSI'): # 在大世界中始终确认弹窗,与 os_map_goto_globe() 中的弹窗相同 - logger.warning('Perspective error caused by popups') + logger.warning('[地图-摄像机] 弹窗导致透视错误') return False elif self.appear(PORT_SUPPLY_CHECK, offset=(20, 20)): - logger.warning('Perspective error caused by akashi shop') + logger.warning('[地图-摄像机] 明石商店导致透视错误') self.device.click(BACK_ARROW) return False elif self.appear(GAME_TIPS, offset=(20, 20)): - logger.warning('Perspective error caused by game tips') + logger.warning('[地图-摄像机] 游戏提示导致透视错误') self.device.click(GAME_TIPS) return False elif 'Camera outside map' in str(e): @@ -197,7 +228,7 @@ def _update_view(self): self._map_swipe((-int(x.strip()), -int(y.strip()))) # 最后检查游戏是否在运行 elif not self.device.app_is_running(): - logger.error('Trying to update camera but game died') + logger.error('[地图-摄像机] 尝试更新摄像机但游戏已退出') raise GameNotRunningError else: raise e @@ -234,7 +265,7 @@ def _update_view_data(self): y = self.camera[1] if self.camera != (x, y): - logger.attr_align('camera_corrected', f'{location2node(self.camera)} -> {location2node((x, y))}') + logger.attr_align('摄像机修正', f'{location2node(self.camera)} -> {location2node((x, y))}') self.camera = (x, y) self.show_camera() @@ -259,9 +290,9 @@ def update(self, camera=True, wait_swipe=False, allow_error=False): try: prev_center_offset = self._prev_view.center_offset except AttributeError: - logger.warning('Camera.update(wait_swipe=True) but camera has no _prev_view') + logger.warning('[地图-摄像机] Camera.update(wait_swipe=True) 但摄像机无_prev_view') prev_center_offset = None - logger.attr('prev.center_offset', prev_center_offset) + logger.attr('之前中心偏移', prev_center_offset) else: prev_center_offset = None @@ -295,7 +326,7 @@ def is_still_prev(): success = self._update_view() if not success: continue - logger.attr('view.center_offset', self.view.center_offset) + logger.attr('视图中心偏移', self.view.center_offset) if wait_swipe and not swipe_wait_timeout.reached() and success: # 如果第一张截图仍然是之前的视图 # 必须先离开格子中心再重新聚焦 @@ -332,7 +363,7 @@ def predict(self): self.view.show() def show_camera(self): - logger.attr_align('Camera', location2node(self.camera)) + logger.attr_align('摄像机', location2node(self.camera)) def ensure_edge_insight(self, reverse=False, preset=None, swipe_limit=(3, 2), skip_first_update=True): """滑动到左下角直到两条边缘可见。 @@ -347,7 +378,7 @@ def ensure_edge_insight(self, reverse=False, preset=None, swipe_limit=(3, 2), sk Returns: list[tuple]: 滑动记录。 """ - logger.info(f'Ensure edge in sight.') + logger.info(f'[地图-摄像机] 确保边缘在视野内') record = [] x_swipe, y_swipe = np.multiply(swipe_limit, random_direction(self.config.MAP_ENSURE_EDGE_INSIGHT_CORNER)) @@ -372,7 +403,7 @@ def ensure_edge_insight(self, reverse=False, preset=None, swipe_limit=(3, 2), sk break if reverse: - logger.info('Reverse swipes.') + logger.info('[地图-摄像机] 反向滑动') for vector in record[::-1]: x, y = vector if x != 0 or y != 0: @@ -388,7 +419,7 @@ def focus_to(self, location, swipe_limit=(4, 3)): swipe_limit (tuple): (x, y)。滑动限制在 (-x, -y, x, y) 范围内。 """ location = location_ensure(location) - logger.info('Focus to: %s' % location2node(location)) + logger.info('[地图-摄像机] 聚焦到: %s' % location2node(location)) while 1: vector = np.array(location) - self.camera @@ -411,7 +442,7 @@ def full_scan(self, queue=None, must_scan=None, battle_count=0, mystery_count=0, carrier_count (int): 航母计数。 mode (str): 扫描模式,如 'init'、'normal'、'carrier'、'movable'。 """ - logger.info(f'Full scan start, mode={mode}') + logger.info(f'[地图-摄像机] 全图扫描开始, 模式={mode}') self.map.reset_fleet() queue = queue if queue else self.map.camera_data @@ -421,10 +452,10 @@ def full_scan(self, queue=None, must_scan=None, battle_count=0, mystery_count=0, while len(queue) > 0: if self.map.missing_is_none(battle_count, mystery_count, siren_count, carrier_count, mode): if must_scan and queue.count != queue.delete(must_scan).count: - logger.info('Continue scanning.') + logger.info('[地图-摄像机] 继续扫描') pass else: - logger.info('All spawn found, Early stopped.') + logger.info('[地图-摄像机] 找到所有出生点,提前停止') break queue = queue.sort_by_camera_distance(self.camera) @@ -448,7 +479,7 @@ def in_sight(self, location, sight=None): sight (tuple): 视野范围,如 (-3, -1, 3, 2)。 """ location = location_ensure(location) - logger.info('In sight: %s' % location2node(location)) + logger.info('[地图-摄像机] 在视野内: %s' % location2node(location)) if sight is None: sight = self.map.camera_sight @@ -480,7 +511,7 @@ def convert_global_to_local(self, location): location = location_ensure(location) local = np.array(location) - self.camera + self.view.center_loca - logger.info('Global %s (camera=%s) -> Local %s (center=%s)' % ( + logger.info('[地图-摄像机] 全局 %s (摄像机=%s) -> 局部 %s (中心=%s)' % ( location2node(location), location2node(self.camera), location2node(local), @@ -489,7 +520,7 @@ def convert_global_to_local(self, location): if local in self.view: return self.view[local] else: - logger.warning('Convert global to local Failed.') + logger.warning('[地图-摄像机] 全局转局部坐标失败') self.focus_to(location) local = np.array(location) - self.camera + self.view.center_loca return self.view[local] @@ -507,7 +538,7 @@ def convert_local_to_global(self, location): location = location_ensure(location) global_ = np.array(location) + self.camera - self.view.center_loca - logger.info('Global %s (camera=%s) <- Local %s (center=%s)' % ( + logger.info('[地图-摄像机] 全局 %s (摄像机=%s) <- 局部 %s (中心=%s)' % ( location2node(global_), location2node(self.camera), location2node(location), @@ -517,13 +548,13 @@ def convert_local_to_global(self, location): if global_ in self.map: return self.map[global_] else: - logger.warning('Convert local to global Failed.') + logger.warning('[地图-摄像机] 局部转全局坐标失败') self.ensure_edge_insight(reverse=True) global_ = np.array(location) + self.camera - self.view.center_loca return self.map[global_] def full_scan_find_boss(self): - logger.info('Full scan find boss.') + logger.info('[地图-摄像机] 全图扫描找到Boss') self.map.reset_fleet() queue = self.map.select(may_boss=True) @@ -536,11 +567,11 @@ def full_scan_find_boss(self): boss = self.map.select(is_boss=True) boss = boss.add(self.map.select(may_boss=True, is_enemy=True)) if boss: - logger.info(f'Boss found: {boss}') + logger.info(f'[地图-摄像机] 找到Boss: {boss}') self.map.show() return True - logger.warning('No boss found.') + logger.warning('[地图-摄像机] 未找到Boss') return False def get_swipe_area_opt(self, map_vector): diff --git a/module/map/fleet.py b/module/map/fleet.py index 5b4d5322f..3163974c8 100644 --- a/module/map/fleet.py +++ b/module/map/fleet.py @@ -1,3 +1,26 @@ +"""舰队管理和地图行走模块。 + +管理地图探索中的舰队操作,是地图自动化的核心模块。 + +核心职责: +- 舰队位置追踪:实时记录舰队1、舰队2和潜艇的位置 +- 舰队行走:控制舰队在网格间移动,处理移动过程中的各种事件 +- 战斗管理:追踪战斗次数、弹药消耗和敌人状态 +- 回合系统:管理可移动敌人和迷宫的回合机制 +- 路径规划:初始化寻路系统,处理路障和可通行性 +- 潜艇操作:控制潜艇移动和狩猎区域调整 + +行走流程: +1. 初始化地图数据和相机 +2. 扫描全图发现敌人和事件 +3. 规划路径到达目标格子 +4. 处理行走过程中的事件(战斗、伏击、神秘格子) +5. 更新敌人状态和回合计数 +6. 检查是否有敌人移动触发重新规划 + +继承链:Fleet -> Camera -> MapOperation -> AmbushHandler -> Combat -> ... +""" + import itertools import numpy as np @@ -12,6 +35,23 @@ class Fleet(Camera, AmbushHandler): + """舰队管理和地图行走控制器。 + + 追踪多个舰队的位置、战斗状态和弹药,并提供完整的地图行走逻辑。 + 行走过程中自动处理战斗、伏击、空袭、神秘格子等事件。 + + Attributes: + fleet_1_location (tuple): 舰队1的全局坐标位置。 + fleet_2_location (tuple): 舰队2的全局坐标位置。 + fleet_submarine_location (tuple): 潜艇的全局坐标位置。 + battle_count (int): 当前地图中的战斗次数。 + mystery_count (int): 当前地图中的神秘事件次数。 + siren_count (int): 当前地图中的塞壬(精英敌人)战斗次数。 + fleet_ammo (int): 舰队剩余弹药数,初始为 5。 + ammo_count (int): 地图中的弹药补给格数量。 + round (int): 当前回合数,用于可移动敌人系统。 + enemy_round (dict): 敌人出现的回合记录,键为回合数,值为敌人数量。 + """ fleet_1_location = () fleet_2_location = () fleet_submarine_location = () @@ -119,7 +159,7 @@ def round_next(self): if not self.config.MAP_HAS_MOVABLE_ENEMY and not self.config.MAP_HAS_MAZE: return False self.round += 1 - logger.info(f'Round: {self.round}, enemy_round: {self.enemy_round}') + logger.info(f'[地图-回合] 回合: {self.round}, 敌方回合: {self.enemy_round}') def round_battle(self, after_battle=True): """清除敌人后调用此方法。 @@ -330,7 +370,7 @@ def _goto(self, location, expected=''): if self.catch_camera_repositioning(self.map[location]): self.handle_boss_appear_refocus() if sum(self.hp) < 0.01: - logger.warning('Empty HP on all slots, trying hp_get again') + logger.warning('[地图-血量] 所有槽位血量为空,重试获取血量') self.hp_get() if self.config.MAP_FOCUS_ENEMY_AFTER_BATTLE: self.camera = location @@ -401,20 +441,20 @@ def _goto(self, location, expected=''): arrive_checker = True if arrive_checker: if not arrive_timer.started(): - logger.info(f'Arrive {location2node(location)} {arrive_predict}'.strip()) + logger.info(f'[地图-移动] 到达 {location2node(location)} {arrive_predict}'.strip()) arrive_timer.start() arrive_unexpected_timer.start() if result == 'nothing' and not arrive_timer.reached(): continue if expected and result not in expected: if arrive_unexpected_timer.reached(): - logger.warning('Arrive with unexpected result') + logger.warning('[地图-移动] 到达结果异常') else: continue if is_portal: location = self.map[location].portal_link self.camera = location - logger.info(f'Arrive {location2node(location)} confirm. Result: {result}. Expected: {expected}') + logger.info(f'[地图-移动] 到达 {location2node(location)} 确认。结果: {result},预期: {expected}') arrived = True break else: @@ -433,7 +473,7 @@ def _goto(self, location, expected=''): if ambushed_retry.started() and ambushed_retry.reached(): break if walk_timeout.reached(): - logger.warning('Walk timeout. Retrying.') + logger.warning('[地图-移动] 移动超时,重试中') self.predict() self.ensure_edge_insight(skip_first_update=False) break @@ -492,7 +532,7 @@ def goto(self, location, expected='', step_optimize=None, turning_optimize=None) nodes = self.map.find_path(location, step=step, turning_optimize=turning_optimize) for node in nodes: if self.maze_active_on(node): - logger.info(f'Maze is active on {location2node(node)}, bouncing to wait') + logger.info(f'[地图-机关] 迷宫激活于 {location2node(node)},弹跳等待') for _ in range(10): grids = self.map[node].maze_nearby.delete(self.map.select(is_fleet=True)) if grids.select(is_enemy=False): @@ -502,7 +542,7 @@ def goto(self, location, expected='', step_optimize=None, turning_optimize=None) try: self._goto(node, expected=expected if node == nodes[-1] else '') except MapWalkError: - logger.warning('Map walk error.') + logger.warning('[地图-移动] 地图移动错误') self.predict() self.ensure_edge_insight() nodes_ = self.map.find_path(node, step=1, turning_optimize=False) @@ -538,10 +578,10 @@ def show_fleet(self): if self.fleet_current_index == n: text = '[%s]' % text fleets.append(text) - logger.info(' '.join(fleets)) + logger.info('[地图-舰队] ' + ' '.join(fleets)) def show_submarine(self): - logger.info(f'Submarine: {location2node(self.fleet_submarine_location)}') + logger.info(f'[地图-潜艇] 潜艇位置: {location2node(self.fleet_submarine_location)}') def full_scan(self, queue=None, must_scan=None, mode='normal'): if self.config.MAP_HAS_DECOY_ENEMY and mode == 'normal': @@ -553,7 +593,7 @@ def full_scan(self, queue=None, must_scan=None, mode='normal'): if self.config.FLEET_2 and not self.fleet_2_location: fleets = self.map.select(is_fleet=True, is_current_fleet=False) if fleets.count: - logger.info(f'Predict fleet_2 to be {fleets[0]}') + logger.info(f'[地图-舰队] 预测第二舰队为 {fleets[0]}') self.fleet_2_location = fleets[0].location for loca in [self.fleet_1_location, self.fleet_2_location]: @@ -571,7 +611,7 @@ def full_scan_carrier(self): prev = self.map.select(is_enemy=True) self.full_scan(mode='carrier') diff = self.map.select(is_enemy=True).delete(prev) - logger.info(f'Carrier spawn: {diff}') + logger.info(f'[地图-舰队] 航母出生点: {diff}') def full_scan_movable(self, enemy_cleared=True): """敌人移动后调用此方法。 @@ -624,15 +664,15 @@ def track_movable(self, enemy_cleared=True, siren=True): ) matched_before = self.map.to_selected(matched_before) matched_after = self.map.to_selected(matched_after) - logger.info(f'Movable enemy {before} -> {after}') - logger.info(f'Tracked enemy {matched_before} -> {matched_after}') + logger.info(f'[地图-敌舰] 可移动敌舰 {before} -> {after}') + logger.info(f'[地图-敌舰] 跟踪敌舰 {matched_before} -> {matched_after}') # 删除错误预测 # 如果 MAP_HAS_MOVABLE_NORMAL_ENEMY 则保留,这种情况比较混乱 if not self.config.MAP_HAS_MOVABLE_NORMAL_ENEMY: for grid in after.delete(matched_after): if not grid.may_siren: - logger.warning(f'Wrong detection: {grid}') + logger.warning(f'[地图-检测] 错误检测: {grid}') grid.wipe_out() # 预测缺失的塞壬 @@ -641,7 +681,7 @@ def track_movable(self, enemy_cleared=True, siren=True): self.battle_count, self.mystery_count, self.siren_count, self.carrier_count, mode='normal') missing = missing['siren'] if siren else missing['enemy'] if diff and missing != 0: - logger.warning(f'Movable enemy tracking lost: {diff}') + logger.warning(f'[地图-敌舰] 可移动敌舰跟踪丢失: {diff}') # 计算被覆盖的格子 covered = self.map.grid_covered(self.map[self.fleet_current], location=[(0, -2)]) @@ -663,7 +703,7 @@ def track_movable(self, enemy_cleared=True, siren=True): else: for grid in self.map.select(is_siren=True): covered = covered.add(self.map.grid_covered(grid)) - logger.attr('enemy_covered', covered) + logger.attr('敌舰覆盖', covered) # 计算塞壬可达格子 accessible = SelectedGrids([]) @@ -685,25 +725,25 @@ def track_movable(self, enemy_cleared=True, siren=True): portal=self.config.MAP_HAS_PORTAL, ) self.map.find_path_initial(self.fleet_current, has_ambush=self.config.MAP_HAS_AMBUSH) - logger.attr('enemy_accessible', accessible) + logger.attr('敌舰可达', accessible) # 取交集进行预测 predict = accessible.intersect(covered).select(is_sea=True, is_fleet=False) - logger.info(f'Movable enemy predict: {predict}') + logger.info(f'[地图-敌舰] 可移动敌舰预测: {predict}') matched_after = matched_after.add(predict) for grid in predict: if siren: grid.is_siren = True grid.is_enemy = True elif missing == 0: - logger.info(f'Movable enemy tracking drop: {diff}') + logger.info(f'[地图-敌舰] 可移动敌舰跟踪丢弃: {diff}') for grid in matched_after: if grid.location != self.fleet_current: grid.is_movable = True def find_all_fleets(self): - logger.hr('Find all fleets') + logger.hr('查找所有舰队') queue = self.map.select(is_spawn_point=True) while queue: queue = queue.sort_by_camera_distance(self.camera) @@ -717,22 +757,22 @@ def find_all_fleets(self): queue = queue[1:] def find_current_fleet(self): - logger.hr('Find current fleet') + logger.hr('查找当前舰队') if not self.config.POOR_MAP_DATA: fleets = self.map.select(is_fleet=True, is_spawn_point=True) else: fleets = self.map.select(is_fleet=True) - logger.info('Fleets: %s' % str(fleets)) + logger.info('[地图-舰队] 舰队: %s' % str(fleets)) count = fleets.count if count == 1: if not self.config.FLEET_2: self.fleet_1 = fleets[0].location else: - logger.info('Fleet_2 not detected.') + logger.info('[地图-舰队] 未检测到第二舰队') if self.config.POOR_MAP_DATA and not self.map.select(is_spawn_point=True): self.fleet_1 = fleets[0].location elif self.map.select(is_spawn_point=True).count == 2: - logger.info('Predict fleet to be spawn point') + logger.info('[地图-舰队] 预测舰队为出生点') another = self.map.select(is_spawn_point=True).delete(SelectedGrids([fleets[0]]))[0] if fleets[0].is_current_fleet: self.fleet_1 = fleets[0].location @@ -764,24 +804,24 @@ def find_current_fleet(self): self.fleet_1 = fleets[1].location self.fleet_2 = fleets[0].location else: - logger.warning('Current fleet not found') + logger.warning('[地图-舰队] 未找到当前舰队') self.fleet_1 = fleets[0].location self.fleet_2 = fleets[1].location else: if count == 0: - logger.warning('No fleets detected.') + logger.warning('[地图-舰队] 未检测到任何舰队') fleets = self.map.select(is_current_fleet=True) if fleets.count: self.fleet_1 = fleets[0].location if count > 2: - logger.warning('Too many fleets: %s.' % str(fleets)) + logger.warning('[地图-舰队] 舰队过多: %s' % str(fleets)) self.find_all_fleets() self.show_fleet() return self.fleet_current def find_all_submarines(self): - logger.hr('Find all submarines') + logger.hr('查找所有潜艇') queue = self.map.select(is_submarine_spawn_point=True) while queue: queue = queue.sort_by_camera_distance(self.camera) @@ -801,14 +841,14 @@ def find_submarine(self): if count == 1: self.fleet_submarine = fleets[0].location elif count == 0: - logger.info('No submarine found') + logger.info('[地图-潜艇] 未找到潜艇') # 尝试出生点 spawn_point = self.map.select(is_submarine_spawn_point=True) if spawn_point.count == 1: - logger.info(f'Predict the only submarine spawn point {spawn_point[0]} as submarine') + logger.info(f'[地图-潜艇] 预测唯一潜艇出生点 {spawn_point[0]} 为潜艇') self.fleet_submarine = spawn_point[0].location else: - logger.info(f'Having multiple submarine spawn points: {spawn_point}') + logger.info(f'[地图-潜艇] 存在多个潜艇出生点: {spawn_point}') # 尝试被覆盖的格子 covered = SelectedGrids([]) for grid in spawn_point: @@ -816,18 +856,18 @@ def find_submarine(self): covered = covered.filter(lambda g: g.is_enemy or g.is_fleet or g.is_siren or g.is_boss) if covered.count == 1: spawn_point = self.map.grid_covered(covered[0], location=[(0, -1)]) - logger.info(f'Submarine {spawn_point[0]} covered by {covered[0]}') + logger.info(f'[地图-潜艇] 潜艇 {spawn_point[0]} 被 {covered[0]} 覆盖') self.fleet_submarine = spawn_point[0].location else: - logger.info('Found multiple submarine spawn points being covered') + logger.info('[地图-潜艇] 发现多个潜艇出生点被覆盖') # 放弃预测,全面搜索 self.find_all_submarines() else: - logger.warning('Too many submarines: %s.' % str(fleets)) + logger.warning('[地图-潜艇] 潜艇过多: %s' % str(fleets)) self.find_all_submarines() if not len(self.fleet_submarine_location): - logger.warning('Unable to find submarine, assume it is at map center') + logger.warning('[地图-潜艇] 无法找到潜艇,假设在地图中心') shape = self.map.shape center = (shape[0] // 2, shape[1] // 2) self.fleet_submarine = self.map.select(is_land=False).sort_by_camera_distance(center)[0].location @@ -841,7 +881,7 @@ def map_init(self, map_): Args: map_ (CampaignMap): 战役地图对象。 """ - logger.hr('Map init') + logger.hr('地图初始化') self.map_data_init(map_) self.map_control_init() @@ -1012,7 +1052,7 @@ def brute_find_roadblocks(self, grid, fleet=None): return SelectedGrids([]) enemies = self.map.select(is_enemy=True) - logger.info(f'Potential enemy roadblocks: {enemies}') + logger.info(f'[地图-路障] 潜在敌舰路障: {enemies}') for repeat in range(1, enemies.count + 1): for select in itertools.product(enemies, repeat=repeat): for block in select: @@ -1023,13 +1063,13 @@ def brute_find_roadblocks(self, grid, fleet=None): if grid.is_accessible: select = SelectedGrids(list(select)) - logger.info(f'Enemy roadblock: {select}') + logger.info(f'[地图-路障] 敌舰路障: {select}') if backup is not None: self.fleet_current_index = backup self.find_path_initial() return select - logger.warning('Enemy roadblock try exhausted.') + logger.warning('[地图-路障] 敌舰路障尝试耗尽') def catch_camera_repositioning(self, destination): """ @@ -1039,7 +1079,7 @@ def catch_camera_repositioning(self, destination): appear = False for data in self.map.spawn_data: if data.get('battle') == self.battle_count and data.get('boss', 0): - logger.info('Catch camera re-positioning after boss appear') + logger.info('[地图-摄像机] Boss出现后摄像机重新定位') appear = True # if self.config.POOR_MAP_DATA: @@ -1049,7 +1089,7 @@ def catch_camera_repositioning(self, destination): # grids.show() # for grid in grids: # if grid.is_boss: - # logger.info('Catch camera re-positioning after boss appear') + # logger.info('[地图-摄像机] Boss出现后摄像机重新定位') # appear = True # for g in self.map: # g.wipe_out() @@ -1071,7 +1111,7 @@ def handle_boss_appear_refocus(self, preset=None): try: self.update() except MapDetectionError: - logger.info(f'MapDetectionError occurs after boss appear, trying swipe preset {preset}') + logger.info(f'[地图-摄像机] Boss出现后发生地图检测错误,尝试滑动预设 {preset}') # Swipe optimize here may not be accurate. self.map_swipe(preset) self.ensure_edge_insight() @@ -1079,7 +1119,7 @@ def handle_boss_appear_refocus(self, preset=None): self.update() self.ensure_edge_insight() - logger.info('Refocus to previous camera position.') + logger.info('[地图-摄像机] 重新聚焦到之前的摄像机位置') self.focus_to(camera) def fleet_checked_reset(self): @@ -1127,19 +1167,19 @@ def _submarine_goto(self, location): moved = False if arrive_checker: if not arrive_timer.started(): - logger.info(f'Arrive {location2node(location)}') + logger.info(f'[地图-移动] 到达 {location2node(location)}') arrive_timer.start() if not arrive_timer.reached(): continue - logger.info(f'Submarine arrive {location2node(location)} confirm.') + logger.info(f'[地图-潜艇] 潜艇到达 {location2node(location)} 确认') if not moved: - logger.info(f'Submarine already at {location2node(location)}') + logger.info(f'[地图-潜艇] 潜艇已在 {location2node(location)}') arrived = True break # End if walk_timeout.reached(): - logger.warning('Walk timeout. Retrying.') + logger.warning('[地图-移动] 移动超时,重试中') self.predict() self.ensure_edge_insight(skip_first_update=False) break @@ -1188,11 +1228,11 @@ def submarine_move_near_boss(self, boss): if not (self.is_call_submarine_at_boss and self.map.select(is_submarine_spawn_point=True)): return False if self.config.Submarine_DistanceToBoss == 'use_open_ocean_support': - logger.info('Going to use Open Ocean Support, skip moving submarines') + logger.info('[地图-潜艇] 将使用远洋支援,跳过移动潜艇') return False boss = location_ensure(boss) - logger.info(f'Move submarine near {location2node(boss)}') + logger.info(f'[地图-潜艇] 移动潜艇到 {location2node(boss)} 附近') self.map.find_path_initial(self.fleet_submarine_location, has_ambush=False, has_enemy=False) self.map.show_cost() @@ -1203,10 +1243,10 @@ def get_location(distance=2): if grids: return grids.sort('cost')[0].location elif distance > 0: - logger.info(f'Unable to find a grid near boss in distance {distance}, fallback to {distance - 1}') + logger.info(f'[地图-潜艇] 无法在距离 {distance} 内找到Boss附近格子,回退到 {distance - 1}') return get_location(distance - 1) else: - logger.warning(f'Unable to find a grid near boss in distance {distance}, return boss position') + logger.warning(f'[地图-潜艇] 无法在距离 {distance} 内找到Boss附近格子,返回Boss位置') return boss distance_dict = { @@ -1215,14 +1255,14 @@ def get_location(distance=2): '2_grid_to_boss': 2 } distance_to_boss = distance_dict.get(self.config.Submarine_DistanceToBoss, 0) - logger.attr('Distance to boss', distance_to_boss) + logger.attr('距Boss距离', distance_to_boss) if np.sum(np.abs(np.subtract(self.fleet_submarine_location, boss))) <= distance_to_boss: - logger.info('Boss is already in hunting zone') + logger.info('[地图-潜艇] Boss已在狩猎区域内') self.find_path_initial() return False else: near = get_location(distance_to_boss) self.find_path_initial() - logger.info(f'Move submarine to {location2node(near)}') + logger.info(f'[地图-潜艇] 移动潜艇到 {location2node(near)}') return self.submarine_goto(near) diff --git a/module/map/map.py b/module/map/map.py index 2c296f4cd..78cd6b85c 100644 --- a/module/map/map.py +++ b/module/map/map.py @@ -1,3 +1,22 @@ +"""地图探索和战斗编排模块。 + +整合舰队管理、路径规划和敌人优先级系统, +提供完整的地图探索和战斗编排逻辑。 + +核心功能: +- 敌人清除:按优先级选择并清除地图上的敌人 +- 神秘格子处理:踩踏神秘格子获取道具/弹药 +- Boss 战:定位并挑战 Boss +- 关卡全清:清除地图上所有可击败的敌人 + +敌人优先级系统: +- 通过 EnemyPriority 配置控制敌人选择策略 +- 支持按敌人规模、类型、距离等因素排序 +- 支持可移动敌人(塞壬)的追踪和预测 + +继承自 Fleet,组合了舰队管理、相机控制和战斗系统。 +""" + import itertools import re @@ -8,10 +27,16 @@ from module.map.map_grids import RoadGrids, SelectedGrids from module.map_detection.grid_info import GridInfo +# 敌人过滤器 ENEMY_FILTER = Filter(regex=re.compile('^(.*?)$'), attr=('str',)) class Map(Fleet): + """地图探索和战斗编排器。 + + 管理地图上的敌人清除、神秘格子处理和 Boss 战。 + 通过敌人优先级系统智能选择下一个目标。 + """ def clear_chosen_enemy(self, grid, expected=''): """ Args: @@ -21,8 +46,8 @@ def clear_chosen_enemy(self, grid, expected=''): Returns: int: 是否清除了敌人。 """ - logger.info('targetEnemyScale:%s' % (self.config.EnemyPriority_EnemyScaleBalanceWeight)) - logger.info('Clear enemy: %s' % grid) + logger.info('[地图-策略] 目标敌舰规模权重:%s' % (self.config.EnemyPriority_EnemyScaleBalanceWeight)) + logger.info('[地图-战斗] 清除敌舰: %s' % grid) expected = f'combat_{expected}' if expected else 'combat' battle_count = self.battle_count self.show_fleet() @@ -40,7 +65,7 @@ def clear_chosen_mystery(self, grid): Args: grid (GridInfo): 目标格子。 """ - logger.info('Clear mystery: %s' % grid) + logger.info('[地图-战斗] 清除神秘点: %s' % grid) self.show_fleet() self.goto(grid, expected='mystery') # self.mystery_count += 1 @@ -54,19 +79,19 @@ def pick_up_ammo(self, grid=None): if grid is None: grid = self.map.select(may_ammo=True) if not grid: - logger.info('Map has no ammo.') + logger.info('[地图-弹药] 地图无弹药点') return False grid = grid[0] if self.ammo_count > 0 and grid.is_accessible: - logger.info('Pick up ammo: %s' % grid) + logger.info('[地图-弹药] 拾取弹药: %s' % grid) self.goto(grid, expected='') self.ensure_no_info_bar() # self.ammo_count -= 5 - self.battle_count recover = 5 - self.fleet_ammo recover = 3 if recover > 3 else recover - logger.attr('Got ammo', recover) + logger.attr('获得弹药', recover) self.ammo_count -= recover self.fleet_ammo += recover @@ -89,14 +114,14 @@ def clear_mechanism(self, grids=None): grids = self.select_grids(grids, is_accessible=True, sort=('weight', 'cost')) for grid in grids: - logger.info(f'Clear mechanism: {grid}') + logger.info(f'[地图-机关] 清除机关: {grid}') self.goto(grid) self.map.show_cost() - logger.info(f'Mechanism trigger release: {grid.mechanism_trigger}') - logger.info(f'Mechanism block release: {grid.mechanism_block}') + logger.info(f'[地图-机关] 机关触发释放: {grid.mechanism_trigger}') + logger.info(f'[地图-机关] 机关障碍释放: {grid.mechanism_block}') raise MapEnemyMoved - logger.info('Mechanism all cleared') + logger.info('[地图-机关] 所有机关已清除') return False @staticmethod @@ -166,7 +191,7 @@ def show_select_grids(grids, **kwargs): text = ', '.join(text) logger.info(text) - logger.info(f'Grids: {grids}') + logger.info(f'[地图] 格子: {grids}') def clear_all_mystery(self, **kwargs): """拾取所有神秘事件的方法。 @@ -182,7 +207,7 @@ def clear_all_mystery(self, **kwargs): if not grids: break - logger.hr('Clear all mystery') + logger.hr('清除所有神秘点') self.show_select_grids(grids, **kwargs) self.clear_chosen_mystery(grids[0]) @@ -206,7 +231,7 @@ def clear_enemy(self, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear enemy') + logger.hr('清除敌舰') self.show_select_grids(grids, **kwargs) self.clear_chosen_enemy(grids[0]) return True @@ -236,7 +261,7 @@ def clear_roadblocks(self, roads, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear roadblock') + logger.hr('清除路障') self.show_select_grids(grids, **kwargs) self.clear_chosen_enemy(grids[0]) return True @@ -266,7 +291,7 @@ def clear_potential_roadblocks(self, roads, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Avoid potential roadblock') + logger.hr('避开潜在路障') self.show_select_grids(grids, **kwargs) self.clear_chosen_enemy(grids[0]) return True @@ -289,7 +314,7 @@ def clear_first_roadblocks(self, roads, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear first roadblock') + logger.hr('清除首个路障') self.show_select_grids(grids, **kwargs) self.clear_chosen_enemy(grids[0]) return True @@ -310,7 +335,7 @@ def clear_grids_for_faster(self, grids, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear grids for faster') + logger.hr('清除格子加速') self.show_select_grids(grids, **kwargs) self.clear_chosen_enemy(grids[0]) return True @@ -326,21 +351,21 @@ def clear_boss(self): """ grids = self.map.select(is_boss=True, is_accessible=True) grids = grids.add(self.map.select(may_boss=True, is_caught_by_siren=True)) - logger.info('Is boss: %s' % grids) + logger.info('[地图-Boss] 是否Boss: %s' % grids) if not grids.count: grids = grids.add(self.map.select(may_boss=True, is_enemy=True, is_accessible=True)) - logger.warning('Boss not detected, using may_boss grids.') - logger.info('May boss: %s' % self.map.select(may_boss=True)) - logger.info('May boss and is enemy: %s' % self.map.select(may_boss=True, is_enemy=True)) + logger.warning('[地图-Boss] 未检测到Boss,使用可能的Boss格子') + logger.info('[地图-Boss] 可能的Boss: %s' % self.map.select(may_boss=True)) + logger.info('[地图-Boss] 可能的Boss且是敌舰: %s' % self.map.select(may_boss=True, is_enemy=True)) if grids: self.submarine_move_near_boss(grids[0]) - logger.hr('Clear BOSS') + logger.hr('清除Boss') grids = grids.sort('weight', 'cost') - logger.info('Grids: %s' % str(grids)) + logger.info('[地图] 格子: %s' % str(grids)) self.clear_chosen_enemy(grids[0], expected='boss') - logger.warning('BOSS not detected, trying all boss spawn point.') + logger.warning('[地图-Boss] 未检测到Boss,尝试所有Boss出生点') return self.clear_potential_boss() def capture_clear_boss(self): @@ -354,27 +379,27 @@ def capture_clear_boss(self): grids = self.map.select(is_boss=True, is_accessible=True) grids = grids.add(self.map.select(may_boss=True, is_caught_by_siren=True)) - logger.info('Is boss: %s' % grids) + logger.info('[地图-Boss] 是否Boss: %s' % grids) if not grids.count: grids = grids.add(self.map.select(may_boss=True, is_enemy=True, is_accessible=True)) - logger.warning('Boss not detected, using may_boss grids.') - logger.info('May boss: %s' % self.map.select(may_boss=True)) - logger.info('May boss and is enemy: %s' % self.map.select(may_boss=True, is_enemy=True)) + logger.warning('[地图-Boss] 未检测到Boss,使用可能的Boss格子') + logger.info('[地图-Boss] 可能的Boss: %s' % self.map.select(may_boss=True)) + logger.info('[地图-Boss] 可能的Boss且是敌舰: %s' % self.map.select(may_boss=True, is_enemy=True)) if grids: - logger.hr('Clear BOSS') + logger.hr('清除Boss') grids = grids.sort('weight', 'cost') - logger.info('Grids: %s' % str(grids)) + logger.info('[地图] 格子: %s' % str(grids)) self.clear_chosen_enemy(grids[0]) - logger.warning('Grand Capture detected, Withdrawing.') + logger.warning('[地图-Boss] 检测到大世界捕获,撤退中') self.withdraw() def clear_potential_boss(self): """当 Boss 未被检测到时,踩踏所有 Boss 出生点的方法。 """ grids = self.map.select(may_boss=True, is_accessible=True).sort('weight', 'cost') - logger.info('May boss: %s' % grids) + logger.info('[地图-Boss] 可能的Boss: %s' % grids) battle_count = self.battle_count is_single_boss = self.map.select(may_boss=True).count == 1 if is_single_boss: @@ -383,24 +408,24 @@ def clear_potential_boss(self): expected = '' for grid in grids: - logger.hr('Clear potential BOSS') + logger.hr('清除潜在Boss') grids = grids.sort('weight', 'cost') - logger.info('Grid: %s' % str(grid)) + logger.info('[地图] 格子: %s' % str(grid)) self.fleet_boss.clear_chosen_enemy(grid, expected=expected) if self.battle_count > battle_count: - logger.info('Boss guessing correct.') + logger.info('[地图-Boss] Boss猜测正确') return True else: - logger.info('Boss guessing incorrect.') + logger.info('[地图-Boss] Boss猜测错误') grids = self.map.select(may_boss=True, is_accessible=False).sort('weight', 'cost') - logger.info('May boss: %s' % grids) + logger.info('[地图-Boss] 可能的Boss: %s' % grids) for grid in grids: - logger.hr('Clear potential BOSS roadblocks') + logger.hr('清除潜在Boss路障') roadblocks = self.brute_find_roadblocks(grid, fleet=self.fleet_boss_index) roadblocks = roadblocks.sort('weight', 'cost') - logger.info('Grids: %s' % str(roadblocks)) + logger.info('[地图] 格子: %s' % str(roadblocks)) self.fleet_1.clear_chosen_enemy(roadblocks[0], expected=expected) return True @@ -412,24 +437,24 @@ def brute_clear_boss(self): """ boss = self.map.select(is_boss=True) if boss: - logger.info('Brute clear BOSS') + logger.info('[地图-Boss] 强制清除Boss') grids = self.brute_find_roadblocks(boss[0], fleet=self.fleet_boss_index) if grids: if self.brute_fleet_meet(): return True - logger.info('Brute clear BOSS roadblocks') + logger.info('[地图-Boss] 强制清除Boss路障') grids = grids.sort('weight', 'cost') - logger.info('Grids: %s' % str(grids)) + logger.info('[地图] 格子: %s' % str(grids)) self.clear_chosen_enemy(grids[0]) return True else: return self.fleet_boss.clear_boss() elif self.map.select(may_boss=True, is_caught_by_siren=True): - logger.info('BOSS appear on fleet grid') + logger.info('[地图-Boss] Boss出现在舰队格子上') self.fleet_2.switch_to() return self.clear_chosen_enemy(self.map.select(may_boss=True, is_caught_by_siren=True)[0]) else: - logger.warning('BOSS not detected, trying all boss spawn point.') + logger.warning('[地图-Boss] 未检测到Boss,尝试所有Boss出生点') return self.clear_potential_boss() def brute_fleet_meet(self): @@ -439,9 +464,9 @@ def brute_fleet_meet(self): return False grids = self.brute_find_roadblocks(self.map[self.fleet_2_location], fleet=1) if grids: - logger.info('Brute clear roadblocks between fleets.') + logger.info('[地图-Boss] 强制清除舰队间路障') grids = grids.sort('weight', 'cost') - logger.info('Grids: %s' % str(grids)) + logger.info('[地图] 格子: %s' % str(grids)) self.clear_chosen_enemy(grids[0]) return True else: @@ -464,7 +489,7 @@ def clear_siren(self, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear siren') + logger.hr('清除塞壬') self.show_select_grids(grids, **kwargs) if grids[0].is_fortress: expected = 'fortress' @@ -491,7 +516,7 @@ def clear_any_enemy(self, **kwargs): grids = self.select_grids(grids, **kwargs) if grids: - logger.hr('Clear enemy') + logger.hr('清除敌舰') self.show_select_grids(grids, **kwargs) grid = grids[0] if grid.is_fortress: @@ -527,17 +552,17 @@ def fleet_2_step_on(self, grids, roadblocks): # return False all_cleared = grids.select(is_cleared=True).count == grids.count - logger.info('Fleet 2 step on') + logger.info('[地图-舰队] 第二舰队踩点') for grid in grids: if grid.is_enemy or (not all_cleared and grid.is_cleared): continue if self.check_accessibility(grid=grid, fleet=2): - logger.info('Fleet_2 step on %s' % grid) + logger.info('[地图-舰队] 第二舰队踩点 %s' % grid) self.fleet_2.goto(grid) self.fleet_1.switch_to() return False - logger.info('Fleet_2 step on got roadblocks.') + logger.info('[地图-舰队] 第二舰队踩点遇到路障') clear = self.fleet_1.clear_roadblocks(roadblocks) self.fleet_1.clear_all_mystery() return clear @@ -548,15 +573,15 @@ def fleet_2_break_siren_caught(self): if not self.config.MAP_HAS_SIREN or not self.config.MAP_HAS_MOVABLE_ENEMY: return False if not self.map.select(is_caught_by_siren=True): - logger.info('No fleet caught by siren.') + logger.info('[地图-舰队] 没有舰队被塞壬捕获') return False if not self.fleet_2_location or not self.map[self.fleet_2_location].is_caught_by_siren: - logger.warning('Appear caught by siren, but not fleet_2.') + logger.warning('[地图-舰队] 出现塞壬捕获,但不是第二舰队') for grid in self.map: grid.is_caught_by_siren = False return False - logger.info(f'Break siren caught, fleet_2: {self.fleet_2_location}') + logger.info(f'[地图-舰队] 打破塞壬捕获,第二舰队: {self.fleet_2_location}') self.fleet_2.switch_to() self.ensure_edge_insight() self.clear_chosen_enemy(self.map[self.fleet_2_location]) @@ -579,24 +604,24 @@ def fleet_2_push_forward(self): if self.fleet_boss_index != 2: return False - logger.info('Fleet_2 push forward') + logger.info('[地图-舰队] 第二舰队推进') grids = self.map.select(is_land=False).sort('weight', 'cost') if self.map[self.fleet_2_location].weight <= grids[0].weight: - logger.info('Fleet_2 pushed to destination') + logger.info('[地图-舰队] 第二舰队已推送到目的地') self.fleet_1.switch_to() return False fleets = SelectedGrids([self.map[self.fleet_1_location], self.map[self.fleet_2_location]]) grids = grids.select(is_accessible_2=True, is_sea=True).delete(fleets) if not grids: - logger.info('Fleet_2 has no where to push') + logger.info('[地图-舰队] 第二舰队无处可推') return False if self.map[self.fleet_2_location].weight <= grids[0].weight: - logger.info('Fleet_2 pushed to closest grid') + logger.info('[地图-舰队] 第二舰队已推送到最近格子') return False - logger.info(f'Grids: {grids}') - logger.info(f'Push forward: {grids[0]}') + logger.info(f'[地图] 格子: {grids}') + logger.info(f'[地图-舰队] 推进: {grids[0]}') self.fleet_2.goto(grids[0]) self.fleet_1.switch_to() return True @@ -616,7 +641,7 @@ def fleet_2_rescue(self, grid): grids = self.brute_find_roadblocks(grid, fleet=2) if not grids: return False - logger.info('Fleet_2 rescue') + logger.info('[地图-舰队] 第二舰队救援') grids = self.select_grids(grids) if not grids: return False @@ -654,7 +679,7 @@ def fleet_2_protect(self): self.goto(grids[0]) continue - logger.warning('fleet_2_protect no siren approaching') + logger.warning('[地图-舰队] 第二舰队保护:无塞壬接近') return False def clear_filter_enemy(self, string, preserve=0): @@ -687,12 +712,12 @@ def clear_filter_enemy(self, string, preserve=0): return False grids = ENEMY_FILTER.apply(grids.sort('weight', 'cost').grids) - logger.info(f'Filter enemy: {grids}, preserve={preserve}') + logger.info(f'[地图-战斗] 筛选敌舰: {grids}, 保留={preserve}') if preserve: grids = grids[preserve:] if grids: - logger.hr('Clear filter enemy') + logger.hr('清除筛选敌舰') self.clear_chosen_enemy(grids[0]) return True @@ -716,8 +741,8 @@ def clear_bouncing_enemy(self): if route is None: return False - logger.hr('Clear bouncing enemy') - logger.info(f'Clear bouncing enemy: {route}') + logger.hr('清除弹跳敌舰') + logger.info(f'[地图-战斗] 清除弹跳敌舰: {route}') self.show_fleet() prev = self.battle_count for n, grid in enumerate(itertools.cycle(route)): @@ -726,14 +751,14 @@ def clear_bouncing_enemy(self): self.goto(grid, expected='combat_nothing') if self.battle_count > prev: - logger.info('Cleared an bouncing enemy') + logger.info('[地图-战斗] 已清除一个弹跳敌舰') route.select(may_bouncing_enemy=True).set(may_bouncing_enemy=False) self.full_scan() self.find_path_initial() self.map.show_cost() return True if n >= 12: - logger.warning('Failed to clear bouncing enemy after 12 trial') + logger.warning('[地图-战斗] 尝试12次后仍无法清除弹跳敌舰') return False return False diff --git a/module/map/map_base.py b/module/map/map_base.py index 3543b755e..48c116a1c 100644 --- a/module/map/map_base.py +++ b/module/map/map_base.py @@ -1,3 +1,16 @@ +"""战役地图基础数据结构。 + +本模块定义了战役地图的核心数据模型,包括地图对象 ``CampaignMap``、 +格子集合 ``SelectedGrids``(来自 map_grids)以及单个战役格子 ``CampaignGrid``。 + +主要职责: +- 存储和解析地图数据(海洋、陆地、出生点、Boss 等格子类型) +- 管理地图机制数据(传送门、墙壁、迷宫、堡垒、陆基等) +- 提供基于 Dijkstra 的寻路算法和路径优化 +- 管理敌人刷新数据(spawn_data)和缺失敌人预测 +- 处理地图更新(合并摄像头扫描的局部数据到全局地图) +""" + import copy from module.base.utils import location2node, node2location @@ -8,6 +21,40 @@ class CampaignMap: + """战役地图数据结构。 + + 管理整个战役地图的格子信息、机制数据、寻路逻辑和敌人刷新预测。 + 每个战役关卡对应一个 CampaignMap 实例,包含地图形状、格子数据、 + 传送门、墙壁、迷宫、堡垒等机制的完整描述。 + + Attributes: + name (str): 地图名称。 + grid_class: 格子对象的类,默认为 ``GridInfo``。 + grids (dict[tuple, GridInfo]): 以坐标 ``(x, y)`` 为键的格子字典。 + _shape (tuple[int, int]): 地图尺寸 ``(width, height)``。 + _map_data (str): 默认地图数据文本。 + _map_data_loop (str): 快进/清理模式地图数据文本。 + _weight_data (str): 格子权重数据文本。 + _wall_data (str): 墙壁数据文本。 + _portal_data (list[tuple]): 传送门数据 ``[(start, end), ...]``。 + _land_based_data (list): 陆基机制数据。 + _maze_data (list): 迷宫机制数据。 + maze_round (int): 迷宫机制所需的回合数。 + _fortress_data (list): 堡垒数据 ``[enemy_grids, block_grids]``。 + _bouncing_enemy_data (list[SelectedGrids]): 弹跳敌人路线数据。 + _spawn_data (list[dict]): 默认敌人刷新数据。 + _spawn_data_stack (list[dict]): 累积刷新统计。 + _spawn_data_loop (list[dict]): 快进模式刷新数据。 + _spawn_data_use_loop (bool): 是否使用快进模式刷新数据。 + _camera_data (SelectedGrids): 相机位置数据。 + _camera_data_spawn_point (SelectedGrids): 出生点检测专用相机位置。 + _map_covered (SelectedGrids): 被覆盖的格子集合。 + _ignore_prediction (list): 忽略的错误预测列表。 + poor_map_data (bool): 地图数据是否不完整。 + camera_sight (tuple[int, int, int, int]): 相机视野范围。 + grid_connection (dict): 格子连接关系。 + """ + def __init__(self, name=None): self.name = name self.grid_class = GridInfo @@ -37,6 +84,11 @@ def __init__(self, name=None): self.grid_connection = {} def __iter__(self): + """迭代地图中所有格子。 + + Yields: + GridInfo: 地图中的每个格子对象。 + """ return iter(self.grids.values()) def __getitem__(self, item): @@ -50,6 +102,14 @@ def __getitem__(self, item): return self.grids[tuple(item)] def __contains__(self, item): + """判断坐标是否在地图范围内。 + + Args: + item: 网格坐标。 + + Returns: + bool: 坐标是否存在于地图中。 + """ return tuple(item) in self.grids @staticmethod @@ -70,6 +130,13 @@ def _parse_text(text): @property def shape(self): + """地图尺寸。 + + 设置时会根据尺寸初始化所有格子,生成默认相机数据,并将权重设为 10。 + + Returns: + tuple[int, int]: 地图尺寸 ``(width, height)``。 + """ return self._shape @shape.setter @@ -90,6 +157,13 @@ def shape(self, scale): @property def map_data(self): + """默认地图数据。 + + 设置时会自动解析并加载地图格子信息。 + + Returns: + str: 默认地图数据文本。 + """ return self._map_data @map_data.setter @@ -99,6 +173,11 @@ def map_data(self, text): @property def map_data_loop(self): + """快进/清理模式地图数据。 + + Returns: + str: 快进模式地图数据文本。 + """ return self._map_data_loop @map_data_loop.setter @@ -112,13 +191,20 @@ def load_map_data(self, use_loop=False): 清理模式(正确名称)== 快进模式(旧版 Alas)== loop(lua 文件中) """ has_loop = bool(len(self.map_data_loop)) - logger.info(f'Load map_data, has_loop={has_loop}, use_loop={use_loop}') + logger.info(f'[地图-数据] 加载地图数据, 有回路={has_loop}, 使用回路={use_loop}') if has_loop and use_loop: self._load_map_data(self.map_data_loop) else: self._load_map_data(self.map_data) def _load_map_data(self, text): + """将文本格式的地图数据解析并写入格子。 + + 如果格子尚未初始化,会先根据数据尺寸设置地图形状。 + + Args: + text (str): 以空格分隔、换行分隔的网格数据文本。 + """ if not len(self.grids.keys()): grids = np.array([loca for loca, _ in self._parse_text(text)]) self.shape = location2node(tuple(np.max(grids, axis=0))) @@ -128,6 +214,13 @@ def _load_map_data(self, text): @property def wall_data(self): + """墙壁数据文本。 + + 设置时仅保存文本,实际加载由 ``grid_connection_initial(wall=True)`` 执行。 + + Returns: + str: 墙壁数据文本。 + """ return self._wall_data @wall_data.setter @@ -136,6 +229,13 @@ def wall_data(self, text): @property def portal_data(self): + """传送门数据。 + + 设置时会解析传送门对并标记源格子为传送门。 + + Returns: + list[tuple]: 传送门数据 ``[(start_location, end_location), ...]``。 + """ return self._portal_data @portal_data.setter @@ -151,6 +251,11 @@ def portal_data(self, portal_list): @property def land_based_data(self): + """陆基机制数据。 + + Returns: + list: 陆基数据,每个元素为 ``[grid_node, rotation]``。 + """ return self._land_based_data @land_based_data.setter @@ -181,6 +286,11 @@ def _load_land_base_data(self, data): @property def maze_data(self): + """迷宫机制数据。 + + Returns: + list: 迷宫数据,每个元素为包含三组坐标的元组。 + """ return self._maze_data @maze_data.setter @@ -188,9 +298,12 @@ def maze_data(self, data): self._maze_data = data def _load_maze_data(self, data): - """ + """加载迷宫机制数据并标记相关格子。 + + 为每个迷宫组设置 ``is_maze`` 标记和回合范围,并计算迷宫格子附近的可达区域。 + Args: - data (list): 例如 [('D5', 'I4', 'J6'), ('C4', 'E4', 'D8'), ('C2', 'G2', 'G6')] + data (list): 迷宫数据,例如 [('D5', 'I4', 'J6'), ('C4', 'E4', 'D8'), ('C2', 'G2', 'G6')] """ self._maze_data = data self.maze_round = len(data) * 3 @@ -203,6 +316,11 @@ def _load_maze_data(self, data): @property def fortress_data(self): + """堡垒机制数据。 + + Returns: + list: ``[enemy_grids, block_grids]``,敌人格子和阻挡格子。 + """ return self._fortress_data @fortress_data.setter @@ -215,7 +333,10 @@ def fortress_data(self, data): self._fortress_data = [enemy, block] def _load_fortress_data(self, data): - """ + """加载堡垒机制数据并标记相关格子。 + + 将敌人格子标记为 ``is_fortress=True``,将阻挡格子标记为 ``is_mechanism_block=True``。 + Args: data (list): [fortress_enemy, fortress_block],可以是字符串或字符串的元组/列表。 例如 [('B5', 'E2', 'H5', 'E8'), 'G3'] 或 ['F5', 'G1'] @@ -227,6 +348,11 @@ def _load_fortress_data(self, data): @property def bouncing_enemy_data(self): + """弹跳敌人路线数据。 + + Returns: + list[SelectedGrids]: 弹跳敌人路线列表,每条路线为一个格子集合。 + """ return self._bouncing_enemy_data @bouncing_enemy_data.setter @@ -243,7 +369,17 @@ def _load_bouncing_enemy_data(self, data): route.set(may_bouncing_enemy=True) def load_mechanism(self, land_based=False, maze=False, fortress=False, bouncing_enemy=False): - logger.info(f'Load mechanism, land_base={land_based}, maze={maze}, fortress={fortress}, ' + """加载地图机制数据。 + + 根据标志位决定加载哪些机制数据到地图格子上。 + + Args: + land_based (bool): 是否加载陆基机制。 + maze (bool): 是否加载迷宫机制。 + fortress (bool): 是否加载堡垒机制。 + bouncing_enemy (bool): 是否加载弹跳敌人机制。 + """ + logger.info(f'[地图-数据] 加载机制, land_base={land_based}, maze={maze}, fortress={fortress}, ' f'bouncing_enemy={bouncing_enemy}') if land_based: self._load_land_base_data(self.land_based_data) @@ -263,7 +399,7 @@ def grid_connection_initial(self, wall=False, portal=False): Returns: bool: 是否使用了墙壁数据。 """ - logger.info(f'grid_connection: wall={wall}, portal={portal}') + logger.info(f'[地图-连接] 格子连接: 墙壁={wall}, 传送门={portal}') # 生成格子连接关系 total = set([grid for grid in self.grids.keys()]) @@ -311,6 +447,11 @@ def grid_connection_initial(self, wall=False, portal=False): return True def fixup_submarine_fleet(self): + """修正潜艇出生点的错误识别。 + + 当一个格子被识别为舰队但不在出生点上,而其上方的格子是潜艇出生点时, + 将舰队识别修正为潜艇识别。同时清除同时被标记为敌人和舰队的格子。 + """ # 修正潜艇出生点 # 如果一个格子被识别为潜艇,其下方的格子可能被误识别为舰队,因为它们有相同的弹药图标 for grid in self.select(is_fleet=True): @@ -318,7 +459,7 @@ def fixup_submarine_fleet(self): continue for upper in self.grid_covered(grid, location=[(0, -1)]): if upper.is_submarine_spawn_point: - logger.info(f'Fixup submarine spawn point, fleet={grid} -> submarine={upper}') + logger.info(f'[地图-潜艇] 修正潜艇出生点, 舰队={grid} -> 潜艇={upper}') grid.is_fleet = False grid.is_current_fleet = False upper.is_submarine = True @@ -329,19 +470,30 @@ def fixup_submarine_fleet(self): grid.is_current_fleet = False def show(self): + """在日志中显示地图网格。 + + 以文本表格形式打印整个地图,使用格子的 ``str`` 属性表示每个格子的状态。 + """ # logger.info('Showing grids:') - logger.info(' ' + ' '.join([' ' + chr(x + 64 + 1) for x in range(self.shape[0] + 1)])) + logger.info('[地图-显示] ' + ' '.join([' ' + chr(x + 64 + 1) for x in range(self.shape[0] + 1)])) for y in range(self.shape[1] + 1): text = str(y + 1).rjust(2) + ' ' + ' '.join( [self[(x, y)].str if (x, y) in self else ' ' for x in range(self.shape[0] + 1)]) logger.info(text) def update(self, grids, camera, mode='normal'): - """ + """将局部扫描结果合并到全局地图。 + + 通过摄像头偏移将局部格子数据映射到全局坐标,进行预测校验后合并。 + 如果错误预测少于 2 个,则执行实际合并。 + Args: - grids: - camera (tuple): - mode (str): 扫描模式,如 'init'、'normal'、'carrier'、'movable' + grids (MapGrids): 局部扫描得到的格子集合。 + camera (tuple): 摄像头在全局地图中的位置。 + mode (str): 扫描模式,如 'init'、'normal'、'carrier'、'movable'。 + + Returns: + bool: 合并是否成功。 """ offset = np.array(camera) - np.array(grids.center_loca) # grids.show() @@ -353,7 +505,7 @@ def update(self, grids, camera, mode='normal'): if self.ignore_prediction_match(globe=loca, local=grid): continue if not copy.copy(self.grids[loca]).merge(grid, mode=mode): - logger.warning(f"Wrong Prediction. {self.grids[loca]} = '{grid.str}'") + logger.warning(f'[地图-预测] 预测错误. {self.grids[loca]} = "{grid.str}"') failed_count += 1 # 如果错误预测少于 2 个,执行实际合并 @@ -368,14 +520,16 @@ def update(self, grids, camera, mode='normal'): self.fixup_submarine_fleet() return True else: - logger.warning('Too many wrong prediction') + logger.warning('[地图-预测] 预测错误过多') return False def reset(self): + """重置所有格子的状态。""" for grid in self: grid.reset() def reset_fleet(self): + """重置所有格子的当前舰队标记。""" for grid in self: grid.is_current_fleet = False @@ -429,6 +583,11 @@ def spawn_data(self, data_list): @property def spawn_data_loop(self): + """快进模式敌人刷新数据。 + + Returns: + list[dict]: 快进模式下的敌人刷新数据列表。 + """ return self._spawn_data_loop @spawn_data_loop.setter @@ -437,11 +596,21 @@ def spawn_data_loop(self, data_list): @property def spawn_data_stack(self): + """累积的敌人刷新统计数据。 + + Returns: + list[dict]: 每次刷新后的累积敌人统计列表。 + """ return self._spawn_data_stack def load_spawn_data(self, use_loop=False): + """加载敌人刷新数据并构建累积统计。 + + Args: + use_loop (bool): 是否使用快进模式的刷新数据。 + """ has_loop = bool(len(self._spawn_data_loop)) - logger.info(f'Load spawn_data, has_loop={has_loop}, use_loop={use_loop}') + logger.info(f'[地图-数据] 加载出生点数据, 有回路={has_loop}, 使用回路={use_loop}') if has_loop and use_loop: self._spawn_data_use_loop = True self._load_spawn_data(self._spawn_data_loop) @@ -450,6 +619,12 @@ def load_spawn_data(self, use_loop=False): self._load_spawn_data(self._spawn_data) def _load_spawn_data(self, data_list): + """解析刷新数据并构建累积统计栈。 + + Args: + data_list (list[dict]): 敌人刷新数据列表,每项包含 'battle'、'enemy'、 + 'mystery'、'siren'、'boss' 等字段。 + """ spawn = {'battle': 0, 'enemy': 0, 'mystery': 0, 'siren': 0, 'boss': 0} for data in data_list: spawn['battle'] = data['battle'] @@ -461,6 +636,13 @@ def _load_spawn_data(self, data_list): @property def weight_data(self): + """格子权重数据。 + + 设置时自动解析并写入每个格子的权重值。 + + Returns: + str: 格子权重数据文本。 + """ return self._weight_data @weight_data.setter @@ -531,6 +713,7 @@ def is_map_data_poor(self): return True def show_cost(self): + """在日志中显示地图各格子的寻路代价。""" logger.info(' ' + ' '.join([' ' + chr(x + 64 + 1) for x in range(self.shape[0] + 1)])) for y in range(self.shape[1] + 1): text = str(y + 1).rjust(2) + ' ' + ' '.join( @@ -538,7 +721,8 @@ def show_cost(self): logger.info(text) def show_connection(self): - logger.info(' ' + ' '.join([' ' + chr(x + 64 + 1) for x in range(self.shape[0] + 1)])) + """在日志中显示地图各格子的寻路连接关系。""" + logger.info('[地图-显示] ' + ' '.join([' ' + chr(x + 64 + 1) for x in range(self.shape[0] + 1)])) for y in range(self.shape[1] + 1): text = str(y + 1).rjust(2) + ' ' + ' '.join( [location2node(self[(x, y)].connection) if (x, y) in self and self[(x, y)].connection else ' ' for x in @@ -623,7 +807,7 @@ def _find_path(self, location): while 1: location = self[location].connection if len(res) > 30: - logger.warning('Route too long') + logger.warning('[地图-路径] 路径过长') logger.warning(res) # exit(1) if location is not None: @@ -633,7 +817,7 @@ def _find_path(self, location): res.reverse() if len(res) == 0: - logger.warning('No path found. Destination: %s' % str(location)) + logger.warning('[地图-路径] 未找到路径。目的地: %s' % str(location)) return [location, location] return res @@ -661,7 +845,7 @@ def _find_route_node(self, route, step=0, turning_optimize=False): if not self[route[index]].is_fleet: res.append(index) else: - logger.info(f'Path_node_avoid: {self[route[index]]}') + logger.info(f'[地图-路径] 避让路径节点: {self[route[index]]}') if (index > 1) and (index - 1 not in indexes): res.append(index - 1) if (index < len(route) - 2) and (index + 1 not in indexes): @@ -683,7 +867,7 @@ def _find_route_node(self, route, step=0, turning_optimize=False): for index in list(range(left, right, step))[1:]: way_node = self[route[index]] if way_node.is_fleet or way_node.is_portal or way_node.is_flare: - logger.info(f'Path_node_avoid: {way_node}') + logger.info(f'[地图-路径] 避让路径节点: {way_node}') if (index > 1) and (index - 1 not in res): inserted.append(index - 1) if (index < len(route) - 2) and (index + 1 not in res): @@ -696,13 +880,26 @@ def _find_route_node(self, route, step=0, turning_optimize=False): return [route[index] for index in res] def find_path(self, location, step=0, turning_optimize=False): + """计算从当前舰队位置到目标位置的路径。 + + 先通过 Dijkstra 算法找到最短路径,然后处理传送门和迷宫分段, + 最后对每段路径提取关键行走节点。 + + Args: + location (str, tuple): 目标网格坐标或节点名。 + step (int): 活动地图中的舰队步数,默认为 0(仅走到终点)。 + turning_optimize (bool): 为 True 时优化路线以减少伏击。 + + Returns: + list[tuple]: 行走节点列表,每个元素为网格坐标。 + """ location = location_ensure(location) path = self._find_path(location) if path is None or not len(path): - logger.warning('No path found. Return destination.') + logger.warning('[地图-路径] 未找到路径,返回目的地') return [location] - logger.info('Full path: %s' % '[' + ', ' .join([location2node(grid) for grid in path]) + ']') + logger.info('[地图-路径] 完整路径: %s' % '[' + ', ' .join([location2node(grid) for grid in path]) + ']') portal_path = [] index = [0] @@ -720,7 +917,7 @@ def find_path(self, location, step=0, turning_optimize=False): local_path = path[start:end + 1] local_path = self._find_route_node(local_path, step=step, turning_optimize=turning_optimize) portal_path += local_path - logger.info('Path: %s' % '[' + ', ' .join([location2node(grid) for grid in local_path]) + ']') + logger.info('[地图-路径] 路径: %s' % '[' + ', ' .join([location2node(grid) for grid in local_path]) + ']') path = portal_path return path @@ -742,6 +939,22 @@ def grid_covered(self, grid, location=None): return SelectedGrids(covered) def missing_get(self, battle_count, mystery_count=0, siren_count=0, carrier_count=0, mode='normal'): + """计算缺失和可能出现的敌人数量。 + + 根据当前战斗次数和已识别的敌人,计算各种敌人类型(普通敌人、 + 神秘、塞壬、Boss、航母)的缺失数量和可能出现的数量。 + + Args: + battle_count (int): 当前战斗次数。 + mystery_count (int): 已遇到的神秘格子数。 + siren_count (int): 已击败的塞壬数。 + carrier_count (int): 已识别的航母数。 + mode (str): 扫描模式。 + + Returns: + tuple[dict, dict]: ``(may, missing)``,may 为各类型可能出现的数量, + missing 为各类型缺失的数量。 + """ try: missing = self.spawn_data_stack[battle_count].copy() except IndexError: @@ -774,13 +987,25 @@ def missing_get(self, battle_count, mystery_count=0, siren_count=0, carrier_coun if upper.may_carrier: may['carrier'] += 1 - logger.attr('enemy_missing', + logger.attr('缺失敌人', ', '.join([f'{k[:2].upper()}:{str(v).rjust(2)}' for k, v in missing.items() if k != 'battle'])) - logger.attr('enemy_may____', + logger.attr('可能敌人', ', '.join([f'{k[:2].upper()}:{str(v).rjust(2)}' for k, v in may.items()])) return may, missing def missing_is_none(self, battle_count, mystery_count=0, siren_count=0, carrier_count=0, mode='normal'): + """判断是否所有敌人已被发现(无缺失)。 + + Args: + battle_count (int): 当前战斗次数。 + mystery_count (int): 已遇到的神秘格子数。 + siren_count (int): 已击败的塞壬数。 + carrier_count (int): 已识别的航母数。 + mode (str): 扫描模式。 + + Returns: + bool: 是否所有敌人都已被发现。 + """ if self.poor_map_data: return False @@ -793,6 +1018,18 @@ def missing_is_none(self, battle_count, mystery_count=0, siren_count=0, carrier_ return True def missing_predict(self, battle_count, mystery_count=0, siren_count=0, carrier_count=0, mode='normal'): + """根据缺失数量预测未探索格子中的敌人。 + + 当某个格子可能是某种敌人且缺失数量等于可能出现的数量时, + 直接将该格子预测为该类型敌人。 + + Args: + battle_count (int): 当前战斗次数。 + mystery_count (int): 已遇到的神秘格子数。 + siren_count (int): 已击败的塞壬数。 + carrier_count (int): 已识别的航母数。 + mode (str): 扫描模式。 + """ if self.poor_map_data: return False @@ -802,11 +1039,11 @@ def missing_predict(self, battle_count, mystery_count=0, siren_count=0, carrier_ for upper in self.map_covered: for attr in ['enemy', 'mystery', 'siren', 'boss']: if upper.__getattribute__('may_' + attr) and missing[attr] > 0 and missing[attr] == may[attr]: - logger.info('Predict %s to be %s' % (location2node(upper.location), attr)) + logger.info('[地图-预测] 预测 %s 为 %s' % (location2node(upper.location), attr)) upper.__setattr__('is_' + attr, True) if carrier_count: if upper.may_carrier and missing['carrier'] > 0 and missing['carrier'] == may['carrier']: - logger.info('Predict %s to be enemy' % location2node(upper.location)) + logger.info('[地图-预测] 预测 %s 为敌舰' % location2node(upper.location)) upper.__setattr__('is_enemy', True) def select(self, **kwargs): diff --git a/module/map/map_fleet_preparation.py b/module/map/map_fleet_preparation.py index 73da15962..557e1bfa0 100644 --- a/module/map/map_fleet_preparation.py +++ b/module/map/map_fleet_preparation.py @@ -1,3 +1,18 @@ +"""舰队准备界面管理模块。 + +管理战役关卡进入前的舰队准备界面操作,包括: +- 舰队选择和切换(通过下拉菜单) +- 舰队推荐按钮 +- 舰队清空操作 +- 困难模式限制条件检测 +- 自动搜索设置(舰队角色分配) + +FleetOperator 类封装了单个舰队槽位的操作逻辑, +支持舰队的激活、停用和状态检测。 + +继承自 InfoHandler,可处理准备界面中的弹窗。 +""" + import numpy as np from scipy import signal @@ -14,6 +29,16 @@ class FleetOperator: + """单个舰队槽位的操作器。 + + 管理舰队准备界面中单个舰队槽位的选择、推荐和状态检测。 + + Attributes: + FLEET_BAR_SHAPE_Y (int): 舰队选择条的高度像素。 + FLEET_BAR_MARGIN_Y (int): 舰队选择条的间距像素。 + FLEET_BAR_ACTIVE_STD (int): 活跃状态的标准差阈值(活跃: 67, 非活跃: 12)。 + FLEET_IN_USE_STD (int): 使用中状态的标准差阈值(使用中: 52, 未使用: 3-6)。 + """ FLEET_BAR_SHAPE_Y = 33 FLEET_BAR_MARGIN_Y = 9 FLEET_BAR_ACTIVE_STD = 45 # Active: 67, inactive: 12. @@ -64,7 +89,7 @@ def parse_fleet_bar(self, image): mean = get_color(image, area) if np.std(mean, ddof=1) > self.FLEET_BAR_ACTIVE_STD: result.append(index + 1) - logger.info('Current selected: %s' % str(result)) + logger.info('[地图-编队] 当前选择: %s' % str(result)) return result def get_button(self, index): @@ -313,14 +338,14 @@ def fleet_preparation(self, skip_first_screenshot=True): Returns: bool: 是否进行了更换。 """ - logger.info(f'Using fleet: {[self.config.Fleet_Fleet1, self.config.Fleet_Fleet2, self.config.Submarine_Fleet]}') + logger.info(f'[地图-编队] 使用舰队: {[self.config.Fleet_Fleet1, self.config.Fleet_Fleet2, self.config.Submarine_Fleet]}') if self.map_fleet_checked: return False # 跳过编队检测:信任游戏内当前预选的舰队,不操作下拉菜单 # 适用于舰队槽位未完全解锁的账号,避免下拉菜单检测卡死 if self.config.Fleet_SkipPreparation: - logger.info('Skip fleet preparation (Fleet_SkipPreparation=True), ' + logger.info('[地图-编队] 跳过舰队准备 (Fleet_SkipPreparation=True), ' 'use current pre-selected fleet in game') return True @@ -338,7 +363,7 @@ def fleet_preparation(self, skip_first_screenshot=True): in_use=FLEET_1_IN_USE, hard_satisfied=FLEET_1_HARD_SATIESFIED, main=self) y = FLEET_1_CLEAR.button[1] - FLEET_1_CLEAR.area[1] if y < -10: - logger.info('FLEET_1_CLEAR moves up, load W15 assets') + logger.info('[地图-编队] FLEET_1_CLEAR上移,加载W15资源') in_use = FLEET_2_IN_USE_W15 else: in_use = FLEET_2_IN_USE @@ -351,7 +376,7 @@ def fleet_preparation(self, skip_first_screenshot=True): # Check if ship is prepared in hard mode h1, h2, h3 = fleet_1.is_hard_satisfied(), fleet_2.is_hard_satisfied(), submarine.is_hard_satisfied() - logger.info(f'Hard satisfied: Fleet_1: {h1}, Fleet_2: {h2}, Submarine: {h3}') + logger.info(f'[地图-编队] 困难满足: 舰队1: {h1}, 舰队2: {h2}, 潜艇: {h3}') if self.config.SERVER in ['cn', 'en', 'jp']: if self.config.Fleet_Fleet1: fleet_1.raise_hard_not_satisfied() @@ -363,7 +388,7 @@ def fleet_preparation(self, skip_first_screenshot=True): # Skip fleet preparation in hard mode self.map_is_hard_mode = h1 is not None or h2 is not None or h3 is not None if self.map_is_hard_mode: - logger.info('Hard Campaign. No fleet preparation') + logger.info('[地图-编队] 困难战役,无需舰队准备') # Clear submarine if user did not set a submarine fleet if submarine.allow(): if self.config.Submarine_Fleet: @@ -378,7 +403,7 @@ def fleet_preparation(self, skip_first_screenshot=True): # cache submarine.allow() to avoid inconsistency after setting fleet_2 # because the expanded fleet_2 may cover submarine buttons map_allow_submarine = submarine.allow() - logger.attr('map_allow_submarine', map_allow_submarine) + logger.attr('允许潜艇', map_allow_submarine) if map_allow_submarine: if self.config.Submarine_Fleet: if fleet_2.allow(): diff --git a/module/map/map_grids.py b/module/map/map_grids.py index 69527c58a..fbed675d0 100644 --- a/module/map/map_grids.py +++ b/module/map/map_grids.py @@ -1,32 +1,88 @@ +"""地图格子集合操作。 + +本模块提供了 ``SelectedGrids`` 和 ``RoadGrids`` 两个核心集合类, +用于对地图上的格子进行批量查询、过滤、排序和集合运算。 + +``SelectedGrids`` 是格子的有序集合,支持属性过滤、索引查询、 +左连接、集合运算(并集、交集、差集)以及多种排序策略。 +``RoadGrids`` 用于表示路径上的障碍格子组合,支持路障检测。 +""" + import operator import typing as t class SelectedGrids: + """地图格子的有序集合。 + + 封装一组格子对象,提供丰富的查询、过滤、排序和集合运算方法。 + 支持迭代、索引、包含检查等 Python 标准协议。 + + Attributes: + grids (list): 格子对象列表。 + indexes (dict): 预计算的索引缓存,由 ``create_index()`` 构建。 + """ + def __init__(self, grids): self.grids = grids self.indexes: t.Dict[tuple, SelectedGrids] = {} def __iter__(self): + """迭代集合中的所有格子。 + + Yields: + 格子对象。 + """ return iter(self.grids) def __getitem__(self, item): + """按索引或切片获取格子。 + + Args: + item (int | slice): 整数索引返回单个格子,切片返回新的 SelectedGrids。 + + Returns: + GridInfo | SelectedGrids: 单个格子或格子子集。 + """ if isinstance(item, int): return self.grids[item] else: return SelectedGrids(self.grids[item]) def __contains__(self, item): + """判断格子是否在集合中。 + + Args: + item: 格子对象。 + + Returns: + bool: 格子是否在集合中。 + """ return item in self.grids def __str__(self): + """返回集合中所有格子的字符串表示。 + + Returns: + str: 以逗号分隔的格子字符串列表。 + """ # return str([str(grid) for grid in self]) return '[' + ', '.join([str(grid) for grid in self]) + ']' def __len__(self): + """返回集合中的格子数量。 + + Returns: + int: 格子数量。 + """ return len(self.grids) def __bool__(self): + """判断集合是否非空。 + + Returns: + bool: 集合是否包含至少一个格子。 + """ return self.count > 0 # def __getattr__(self, item): @@ -34,43 +90,51 @@ def __bool__(self): @property def location(self): - """ + """获取集合中所有格子的坐标。 + Returns: - list[tuple]: + list[tuple]: 坐标列表,每个元素为 ``(x, y)``。 """ return [grid.location for grid in self.grids] @property def cost(self): - """ + """获取集合中所有格子的寻路代价。 + Returns: - list[int]: + list[int]: 代价列表。 """ return [grid.cost for grid in self.grids] @property def weight(self): - """ + """获取集合中所有格子的权重。 + Returns: - list[int]: + list[int]: 权重列表。 """ return [grid.weight for grid in self.grids] @property def count(self): - """ + """获取集合中的格子数量。 + Returns: - int: + int: 格子数量。 """ return len(self.grids) def select(self, **kwargs): - """ + """按属性值过滤格子。 + + 返回一个新集合,仅包含所有指定属性与给定值匹配的格子。 + 属性值要求类型和值都相等。 + Args: - **kwargs: Attributes of Grid. + **kwargs: 格子属性键值对,如 ``is_enemy=True``, ``may_boss=True``。 Returns: - SelectedGrids: + SelectedGrids: 符合条件的格子子集。 """ def matched(obj): flag = True @@ -83,6 +147,16 @@ def matched(obj): return SelectedGrids([grid for grid in self.grids if matched(grid)]) def create_index(self, *attrs): + """根据指定属性创建索引。 + + 将格子按给定属性的值进行分组,建立索引以加速后续的 ``indexed_select`` 查询。 + + Args: + *attrs: 要索引的属性名。 + + Returns: + dict: 索引字典,键为属性值元组,值为对应的 SelectedGrids。 + """ indexes = {} # index_keys = [(grid.__getattribute__(attr) for attr in attrs) for grid in self.grids] for grid in self.grids: @@ -97,18 +171,30 @@ def create_index(self, *attrs): return indexes def indexed_select(self, *values): + """使用预计算索引查询格子。 + + Args: + *values: 索引键值,与 ``create_index`` 中的属性顺序对应。 + + Returns: + SelectedGrids: 匹配的格子集合,无匹配时返回空集合。 + """ return self.indexes.get(values, SelectedGrids([])) def left_join(self, right, on_attr, set_attr, default=None): - """ + """对右侧集合执行左连接操作。 + + 根据 ``on_attr`` 指定的属性将左侧(self)和右侧格子进行匹配, + 并将右侧格子的 ``set_attr`` 属性复制到左侧格子上。 + Args: - right (SelectedGrids): Right table to join - on_attr: - set_attr: - default: + right (SelectedGrids): 右侧集合(要连接的集合)。 + on_attr (list[str]): 连接条件的属性名列表。 + set_attr (list[str]): 需要从右侧复制到左侧的属性名列表。 + default: 当右侧无匹配时,设置的默认值。 Returns: - SelectedGrids: + SelectedGrids: self,属性已被修改。 """ right.create_index(*on_attr) for grid in self: @@ -124,57 +210,54 @@ def left_join(self, right, on_attr, set_attr, default=None): return self def filter(self, func): - """ - Filter grids by a function. + """使用函数过滤格子。 Args: - func (callable): Function should receive an grid as argument, and return a bool. + func (callable): 过滤函数,接收一个格子对象并返回 bool。 Returns: - SelectedGrids: + SelectedGrids: 满足条件的格子子集。 """ return SelectedGrids([grid for grid in self if func(grid)]) def set(self, **kwargs): - """ - Set attribute to each grid. + """批量设置集合中所有格子的属性。 Args: - **kwargs: + **kwargs: 要设置的属性键值对。 """ for grid in self: for key, value in kwargs.items(): grid.__setattr__(key, value) def get(self, attr): - """ - Get an attribute from each grid. + """获取集合中所有格子的指定属性值。 Args: - attr: Attribute name. + attr (str): 属性名。 Returns: - list: + list: 各格子的属性值列表。 """ return [grid.__getattribute__(attr) for grid in self.grids] def call(self, func, **kwargs): - """ - Call a function in reach grid, and get results. + """对集合中每个格子调用指定方法并收集返回值。 Args: - func (str): Function name to call. - **kwargs: + func (str): 方法名。 + **kwargs: 传递给方法的关键字参数。 Returns: - list: + list: 各格子调用结果的列表。 """ return [grid.__getattribute__(func)(**kwargs) for grid in self] def first_or_none(self): - """ - Returns: + """获取集合中的第一个格子,如果集合为空则返回 None。 + Returns: + GridInfo | None: 第一个格子或 None。 """ try: return self.grids[0] @@ -182,24 +265,26 @@ def first_or_none(self): return None def add(self, grids): - """ + """与另一个集合合并(使用 ``__hash__`` 去重)。 + Args: - grids(SelectedGrids): + grids (SelectedGrids): 要合并的格子集合。 Returns: - SelectedGrids: + SelectedGrids: 合并后的格子集合。 """ return SelectedGrids(list(set(self.grids + grids.grids))) def add_by_eq(self, grids): - """ - Another `add()` method, but de-duplicates with `__eq__` instead of `__hash__`. + """与另一个集合合并,使用 ``__eq__`` 去重(而非 ``__hash__``)。 + + 当格子对象未正确实现 ``__hash__`` 时使用此方法替代 ``add()``。 Args: - grids(SelectedGrids): + grids (SelectedGrids): 要合并的格子集合。 Returns: - SelectedGrids: + SelectedGrids: 合并后的格子集合。 """ new = [] for grid in self.grids + grids.grids: @@ -209,24 +294,24 @@ def add_by_eq(self, grids): return SelectedGrids(new) def intersect(self, grids): - """ + """与另一个集合取交集(使用 ``__hash__`` 比较)。 + Args: - grids(SelectedGrids): + grids (SelectedGrids): 要取交集的格子集合。 Returns: - SelectedGrids: + SelectedGrids: 交集格子集合。 """ return SelectedGrids(list(set(self.grids).intersection(set(grids.grids)))) def intersect_by_eq(self, grids): - """ - Another `intersect()` method, but de-duplicates with `__eq__` instead of `__hash__`. + """与另一个集合取交集,使用 ``__eq__`` 比较(而非 ``__hash__``)。 Args: - grids(SelectedGrids): + grids (SelectedGrids): 要取交集的格子集合。 Returns: - SelectedGrids: + SelectedGrids: 交集格子集合。 """ new = [] for grid in self.grids: @@ -236,23 +321,25 @@ def intersect_by_eq(self, grids): return SelectedGrids(new) def delete(self, grids): - """ + """从集合中删除指定格子。 + Args: - grids(SelectedGrids): + grids (SelectedGrids): 要删除的格子集合。 Returns: - SelectedGrids: + SelectedGrids: 删除后的格子集合。 """ g = [grid for grid in self.grids if grid not in grids] return SelectedGrids(g) def sort(self, *args): - """ + """按指定属性对格子排序。 + Args: - args (str): Attribute name to sort. + *args (str): 用于排序的属性名,按优先级从高到低排列。 Returns: - SelectedGrids: + SelectedGrids: 排序后的格子集合。 """ if not self: return self @@ -263,12 +350,13 @@ def sort(self, *args): return self def sort_by_camera_distance(self, camera): - """ + """按与相机位置的曼哈顿距离排序格子。 + Args: - camera (tuple): + camera (tuple): 相机位置坐标 ``(x, y)``。 Returns: - SelectedGrids: + SelectedGrids: 按距离从近到远排序的格子集合。 """ import numpy as np if not self: @@ -280,14 +368,18 @@ def sort_by_camera_distance(self, camera): return SelectedGrids(grids) def sort_by_clock_degree(self, center=(0, 0), start=(0, 1), clockwise=True): - """ + """按时钟角度排序格子。 + + 以 center 为原点,以 start 方向为 0 度,按角度对格子进行排序。 + 默认顺时针排序。 + Args: - center (tuple): Origin point. - start (tuple): Start coordinate, this point will be considered as theta=0. - clockwise (bool): True for clockwise, false for counterclockwise. + center (tuple): 原点坐标。 + start (tuple): 起始方向坐标,此方向被视为 theta=0。 + clockwise (bool): True 为顺时针,False 为逆时针。 Returns: - SelectedGrids: + SelectedGrids: 按角度排序的格子集合。 """ import numpy as np if not self: @@ -304,6 +396,15 @@ def sort_by_clock_degree(self, center=(0, 0), start=(0, 1), clockwise=True): class RoadGrids: + """路径障碍格子组合。 + + 用于表示地图路径上的障碍点,每个障碍点可能对应多个候选格子(例如 + 一个障碍点可能包含两选一的敌人格子)。支持路障检测和路线组合。 + + Attributes: + grids (list[SelectedGrids]): 障碍格子组列表,每个元素是一个候选格子集合。 + """ + def __init__(self, grids): """ Args: @@ -317,12 +418,20 @@ def __init__(self, grids): self.grids.append(SelectedGrids(grids=[grid])) def __str__(self): + """返回路径障碍的字符串表示。 + + Returns: + str: 以 ' - ' 分隔的各障碍点字符串。 + """ return str(' - '.join([str(grid) for grid in self.grids])) def roadblocks(self): - """ + """获取已确认的路障格子。 + + 当一个障碍点中所有格子都是敌人时,该障碍点被视为已确认的路障。 + Returns: - SelectedGrids: + SelectedGrids: 已确认路障的格子集合。 """ grids = [] for block in self.grids: @@ -331,9 +440,13 @@ def roadblocks(self): return SelectedGrids(grids) def potential_roadblocks(self): - """ + """获取潜在路障格子。 + + 当障碍点中仅有一个非敌人格子(即还需要击败一个敌人才能通过), + 且该障碍点中没有舰队或已清除的格子时,返回该障碍点中的敌人格子。 + Returns: - SelectedGrids: + SelectedGrids: 潜在路障中的敌人格子集合。 """ grids = [] for block in self.grids: @@ -346,9 +459,12 @@ def potential_roadblocks(self): return SelectedGrids(grids) def first_roadblocks(self): - """ + """获取第一个需要处理的路障格子。 + + 返回所有未清除且不含舰队的障碍点中的敌人格子。 + Returns: - SelectedGrids: + SelectedGrids: 需要处理的路障敌人格子集合。 """ grids = [] for block in self.grids: @@ -361,12 +477,15 @@ def first_roadblocks(self): return SelectedGrids(grids) def combine(self, road): - """ + """将两条路线的障碍点组合为笛卡尔积。 + + 对 self 和 road 中的每对障碍点取并集,生成所有可能的组合。 + Args: - road (RoadGrids): + road (RoadGrids): 另一条路线的障碍组合。 Returns: - RoadGrids: + RoadGrids: 组合后的障碍集合。 """ out = RoadGrids([]) for select_1 in self.grids: diff --git a/module/map/map_operation.py b/module/map/map_operation.py index 33d9bcdbf..a18c27daa 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -1,3 +1,19 @@ +"""地图操作与战斗准备。 + +本模块提供战役地图中的基础操作,包括: +- 舰队切换与准备(fleet_set、fleet_preparation) +- 进入战役关卡的完整流程(enter_map) +- 地图难度模式切换(handle_map_mode_switch) +- 地图准备阶段处理(handle_map_preparation) +- 撤退操作(withdraw) +- 猫猫攻击跳过(handle_map_cat_attack) +- 舰队顺序反转处理(handle_fleet_reverse) + +``MapOperation`` 继承了 ``MysteryHandler``(神秘格子处理)、 +``FleetPreparation``(舰队准备)、``Retirement``(退役处理) +和 ``FastForwardHandler``(快进处理),组合了进入地图所需的全部子流程。 +""" + import cv2 from module.base.timer import Timer @@ -12,6 +28,19 @@ class MapOperation(MysteryHandler, FleetPreparation, Retirement, FastForwardHandler): + """地图操作处理器。 + + 封装战役地图中的所有基础操作,包括进入关卡、舰队切换、 + 撤退、模式切换等。组合了神秘格子、舰队准备、退役和快进处理。 + + Attributes: + map_cat_attack_timer (Timer): 猫猫攻击检测的节流计时器。 + map_clear_percentage_prev (float): 上一次记录的地图通关百分比。 + map_clear_percentage_timer (Timer): 通关百分比变化检测计时器。 + fleet_show_index (int): 屏幕上显示的舰队编号(1 或 2)。 + fleet_current_index (int): 当前逻辑舰队编号(考虑舰队顺序反转)。 + """ + map_cat_attack_timer = Timer(2) map_clear_percentage_prev = -1 map_clear_percentage_timer = Timer(0.3, count=1) @@ -39,7 +68,7 @@ def get_fleet_show_index(self): self.fleet_show_index = 2 return 2 else: - logger.warning('Unknown fleet current index, use 1 by default') + logger.warning('[地图-操作] 未知的舰队当前索引,默认使用1') self.fleet_show_index = 1 return 1 @@ -68,7 +97,7 @@ def fleet_set(self, index=None, skip_first_screenshot=True): Returns: bool: 是否进行了切换。 """ - logger.info(f'Fleet set to {index}') + logger.info(f'[地图-操作] 舰队设置为 {index}') timeout = Timer(5, count=10).start() count = 0 while 1: @@ -78,7 +107,7 @@ def fleet_set(self, index=None, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning('Fleet set timeout, assume current fleet is correct') + logger.warning('[地图-操作] 舰队设置超时,假设当前舰队正确') break if self.handle_story_skip(): @@ -90,7 +119,7 @@ def fleet_set(self, index=None, skip_first_screenshot=True): self.get_fleet_show_index() self.get_fleet_current_index() - logger.info(f'Fleet: {self.fleet_show_index}, fleet_current_index: {self.fleet_current_index}') + logger.info(f'[地图-操作] 舰队: {self.fleet_show_index}, 当前舰队索引: {self.fleet_current_index}') if self.fleet_current_index == index: break elif self.appear_then_click(SWITCH_OVER): @@ -99,7 +128,7 @@ def fleet_set(self, index=None, skip_first_screenshot=True): timeout.reset() continue else: - logger.warning('SWITCH_OVER not found') + logger.warning('[地图-操作] 未找到切换按钮') continue return count > 0 @@ -113,7 +142,7 @@ def enter_map(self, button, mode='normal', skip_first_screenshot=True): mode (str): 'normal' 或 'hard'。 skip_first_screenshot (bool): 是否跳过第一次截图。 """ - logger.hr('Enter map') + logger.hr('进入地图') campaign_timer = Timer(5) map_timer = Timer(5) fleet_timer = Timer(5) @@ -150,7 +179,7 @@ def enter_map(self, button, mode='normal', skip_first_screenshot=True): # 已在地图中 if not checked_in_map and self.is_in_map(): - logger.info('Already in map, skip enter_map.') + logger.info('[地图-操作] 已在地图中,跳过进入地图') return False else: checked_in_map = True @@ -237,14 +266,14 @@ def enter_map(self, button, mode='normal', skip_first_screenshot=True): # 结束判断 if self.map_is_auto_search: if self.is_auto_search_running(): - logger.info('is_auto_search_running appeared') + logger.info('[地图-操作] 自动搜索运行中出现') break if hasattr(self, 'is_combat_loading') and self.is_combat_loading(): - logger.warning('Entered map with is_combat_loading appeared') + logger.warning('[地图-操作] 进入地图时战斗加载画面出现') break else: if hasattr(self, 'is_combat_loading') and self.is_combat_loading(): - logger.warning('Entered map with is_combat_loading appeared') + logger.warning('[地图-操作] 进入地图时战斗加载画面出现') break if self.handle_in_map_with_enemy_searching(): # self.handle_map_after_combat_story() @@ -253,7 +282,15 @@ def enter_map(self, button, mode='normal', skip_first_screenshot=True): return True def enter_map_cancel(self, skip_first_screenshot=True): - logger.hr('Enter map cancel') + """取消进入地图,从地图准备界面退回关卡选择界面。 + + Args: + skip_first_screenshot (bool): 是否跳过第一次截图。 + + Returns: + bool: 始终返回 True。 + """ + logger.hr('取消进入地图') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -288,29 +325,40 @@ def handle_map_mode_switch(self, mode): if mode == 'normal': if self.match_template_color(MAP_MODE_SWITCH_NORMAL, offset=(20, 20)): - logger.attr('MAP_MODE_SWITCH', 'normal') + logger.attr('地图模式', '普通') return True if self._is_mod_switch_hard_appear(active=False, interval=2): - logger.attr('MAP_MODE_SWITCH', 'hard') + logger.attr('地图模式', '困难') MAP_MODE_SWITCH_NORMAL.clear_offset() self.device.click(MAP_MODE_SWITCH_NORMAL) self.interval_reset(MAP_MODE_SWITCH_HARD) return False elif mode == 'hard': if self._is_mod_switch_hard_appear(active=True): - logger.attr('MAP_MODE_SWITCH', 'hard') + logger.attr('地图模式', '困难') return True if self.match_template_color(MAP_MODE_SWITCH_NORMAL, offset=(20, 20), interval=2): - logger.attr('MAP_MODE_SWITCH', 'normal') + logger.attr('地图模式', '普通') MAP_MODE_SWITCH_HARD.clear_offset() self.device.click(MAP_MODE_SWITCH_HARD) return False return False else: - logger.attr('MAP_MODE_SWITCH', 'unknown') + logger.attr('地图模式', '未知') return False def _is_mod_switch_hard_appear(self, active=True, interval=0): + """检测困难模式切换按钮是否出现。 + + 遍历多个可能的困难模式按钮模板进行匹配。 + + Args: + active (bool): 是否需要检查按钮处于激活状态。 + interval (int): 操作间隔时间(秒)。 + + Returns: + bool: 困难模式按钮是否出现(且如果需要检查,是否处于激活状态)。 + """ if interval: interval = self.get_interval_timer(MAP_MODE_SWITCH_HARD, interval=interval) if not interval.reached(): @@ -332,6 +380,16 @@ def _is_mod_switch_hard_appear(self, active=True, interval=0): return False def _is_mod_switch_hard_active(self, button): + """通过颜色检测判断困难模式按钮是否处于激活状态。 + + 激活状态的按钮包含白色图标(RGB 最大值 > 235 的像素占比 > 50%)。 + + Args: + button (Button): 困难模式切换按钮。 + + Returns: + bool: 按钮是否处于激活状态。 + """ image = self.image_crop(button.button) # 取 RGB 三通道最大值 r, g, b = cv2.split(image) @@ -355,17 +413,17 @@ def handle_map_preparation(self): self.map_clear_percentage_timer.reset() return False if not self.config.MAP_HAS_CLEAR_PERCENTAGE: - logger.attr('MAP_HAS_CLEAR_PERCENTAGE', self.config.MAP_HAS_CLEAR_PERCENTAGE) + logger.attr('地图有通关百分比', self.config.MAP_HAS_CLEAR_PERCENTAGE) return True if self.config.MAP_IS_ONE_TIME_STAGE: - logger.attr('MAP_IS_ONE_TIME_STAGE', self.config.MAP_IS_ONE_TIME_STAGE) + logger.attr('地图是一次性关卡', self.config.MAP_IS_ONE_TIME_STAGE) return True # 信息栏会遮挡进度条和 MAP_GREEN if self.info_bar_count(): return False percent = self.get_map_clear_percentage() - logger.attr('Map_clear_percentage', f'{int(percent * 100)}%') + logger.attr('地图通关百分比', f'{int(percent * 100)}%') # 注意:进度条从 100% 开始,然后从 0% 增加到实际值。 # 2022.08.21 当 `percent` 从 0 上升时仍然启用此逻辑。 if percent > 0.95 and 0 <= self.map_clear_percentage_prev < 0.95: @@ -386,7 +444,7 @@ def withdraw(self, skip_first_screenshot=True): """ 撤退战役。 """ - logger.hr('Map withdraw') + logger.hr('地图撤退') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -418,14 +476,14 @@ def handle_map_cat_attack(self): if not self.map_cat_attack_timer.reached(): return False if self.image_color_count(MAP_CAT_ATTACK, color=(255, 231, 123), threshold=221, count=100): - logger.info('Skip map cat attack') + logger.info('[地图-操作] 跳过地图猫攻击') self.device.click(MAP_CAT_ATTACK) self.map_cat_attack_timer.reset() return True if not self.map_is_clear_mode: # 威胁检测:Medium 模式有 106 像素计数,MAP_CAT_ATTACK_MIRROR 有 290。 if self.image_color_count(MAP_CAT_ATTACK_MIRROR, color=(255, 231, 123), threshold=221, count=200): - logger.info('Skip map being attack') + logger.info('[地图-操作] 跳过地图被攻击') self.device.click(MAP_CAT_ATTACK) self.map_cat_attack_timer.reset() return True diff --git a/module/map/utils.py b/module/map/utils.py index 9556fb2ab..960948276 100644 --- a/module/map/utils.py +++ b/module/map/utils.py @@ -1,3 +1,14 @@ +"""地图工具函数。 + +本模块提供战役地图系统的辅助工具函数,包括: +- 坐标转换:``location_ensure`` 统一坐标格式(节点名/元组/GridInfo 对象) +- 相机位置计算:``camera_1d``、``camera_2d`` 计算覆盖地图所需的相机位置 +- 活动区域检测:``get_map_active_area`` 获取非空格子的边界范围 +- 出生点相机位置:``camera_spawn_point`` 计算出生点附近的最近相机位置 +- 方向随机化:``random_direction`` 从方向描述生成随机方向向量 +- 可移动敌人匹配:``match_movable`` 通过距离矩阵匹配移动前后的敌人 +""" + import numpy as np from module.base.utils import node2location @@ -5,12 +16,18 @@ def location_ensure(location): - """ + """将各种格式的坐标统一转换为元组格式。 + + 支持三种输入格式: + - 带有 ``location`` 属性的对象(如 GridInfo) + - 字符串节点名(如 'D5') + - 元组坐标(如 (3, 4)) + Args: - location: Grid. + location: 网格坐标,可以是 GridInfo 对象、字符串节点名或元组。 Returns: - tuple(int): Location, such as (4, 3) + tuple[int]: 坐标元组,如 ``(4, 3)``。 """ if hasattr(location, 'location'): return location.location @@ -21,6 +38,17 @@ def location_ensure(location): def camera_1d(shape, sight): + """计算一维方向上的相机位置序列。 + + 根据地图长度和相机视野范围,生成能覆盖整行/列的相机位置列表。 + + Args: + shape (int): 地图在该维度上的长度。 + sight (list[int]): 相机视野范围 ``[start, end]``,start 可为负数。 + + Returns: + list[int]: 相机位置列表。 + """ start, step = abs(sight[0]), sight[1] - sight[0] + 1 if shape <= start: out = shape // 2 @@ -32,15 +60,18 @@ def camera_1d(shape, sight): def camera_2d(area, sight): - """ + """计算二维地图上覆盖全部活动区域所需的相机位置网格。 + + 通过在 X 和 Y 方向上分别计算相机位置,再组合成二维网格。 + Args: - area (tuple[int]): Active area on map. (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). - For example: If map shape is I9, but row 1, row 9, line A and line I is empty, - area is (1, 1, 8, 8) - sight (tuple[int]): Camera sight. (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + area (tuple[int]): 地图活动区域 ``(左上X, 左上Y, 右下X, 右下Y)``。 + 例如:地图形状为 I9,但第 1 行、第 9 行、A 列和 I 列为空时, + area 为 ``(1, 1, 8, 8)``。 + sight (tuple[int]): 相机视野 ``(左上X, 左上Y, 右下X, 右下Y)``。 Returns: - list[tuple]: List of camera location. + list[tuple]: 相机位置列表,每个元素为 ``(x, y)`` 坐标。 """ x = camera_1d(shape=area[2] - area[0], sight=[sight[0], sight[2]]) y = camera_1d(shape=area[3] - area[1], sight=[sight[1], sight[3]]) @@ -49,12 +80,17 @@ def camera_2d(area, sight): def get_map_active_area(grids): - """ + """获取地图活动区域的边界。 + + 遍历所有格子,排除海洋(``--``)和陆地(``++``)格子, + 计算剩余活动格子的最小包围矩形。 + Args: - grids (dict): Key: tuple, location, Value: GridInfo or object with __str__ method. + grids (dict): 格子字典,键为坐标元组,值为 GridInfo 或具有 + ``__str__`` 方法的对象。 Returns: - area (tuple): (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y). + tuple: 活动区域 ``(左上X, 左上Y, 右下X, 右下Y)``。 """ def is_active(g): @@ -68,13 +104,17 @@ def is_active(g): def camera_spawn_point(camera_list, sp_list): - """ + """计算出生点附近的最近相机位置。 + + 对于每个出生点,找到曼哈顿距离最近的相机位置, + 用于在出生点位置生成摄像头扫描数据。 + Args: - camera_list (list[tuple]): CampaignMap.camera_data - sp_list (list[tuple]): + camera_list (list[tuple]): 已有的相机位置列表(CampaignMap.camera_data)。 + sp_list (list[tuple]): 出生点坐标列表。 Returns: - list[tuple]: CampaignMap.camera_data_spawn_point + list[tuple]: 出生点检测专用的相机位置列表(去重后)。 """ camera_sp = [] camera_list = np.array(camera_list) @@ -86,15 +126,17 @@ def camera_spawn_point(camera_list, sp_list): def random_direction(direction): - """ - Choose a random direction from string. Missing axis will be random, and '' for all random. + """从方向描述生成随机方向向量。 + + 根据方向字符串确定固定轴的方向,未指定的轴随机生成。 + 空字符串表示完全随机方向。 Args: - direction (str): 'upper-left', 'upper-right', 'bottom-left', - 'bottom-right', or 'upper', 'bottom', 'left', 'right', etc. + direction (str): 方向描述,如 'upper-left'、'upper-right'、'bottom-left'、 + 'bottom-right'、'upper'、'bottom'、'left'、'right' 等。 Returns: - tuple(int): Such as (-1, 1) for bottom-left + tuple[int]: 方向向量,如 ``(-1, 1)`` 表示左下方。 """ direction = direction.lower() x = 1 if np.random.uniform() > 0.5 else -1 @@ -111,6 +153,18 @@ def random_direction(direction): def combine(before, after, limit): + """组合排列候选索引。 + + 为匹配算法生成所有可能的索引组合,确保同一索引不会重复出现。 + + Args: + before (list[list[int]]): 已有的组合列表。 + after (list[int]): 候选索引列表。 + limit (int): 索引上限,等于候选数量。 + + Yields: + list[int]: 组合后的索引列表。 + """ after += [limit] for b in before: for a in after: @@ -121,22 +175,26 @@ def combine(before, after, limit): def match_movable(before, spawn, after, fleets, fleet_step=2): - """ + """匹配移动前后的可移动敌人(如塞壬)。 + + 通过构建距离矩阵和排列搜索,将移动前的敌人位置与移动后的位置 + 进行最优匹配。用于追踪移动型敌人的位移。 + Args: - before (list(tuple)): List of location. Before and after are equivalent, you can reverse input. - Will match the previous element in `before` first. - spawn (list(tuple)): - after (list(tuple)): - fleets (list(tuple)): - fleet_step (int): + before (list[tuple]): 移动前的敌人位置列表。 + spawn (list[tuple]): 可能新增的敌人出生点列表。 + after (list[tuple]): 移动后的敌人位置列表。 + fleets (list[tuple]): 舰队位置列表。 + fleet_step (int): 舰队/敌人的最大移动步数,默认为 2。 Returns: - list(tuple), list(tuple): Matched before, and after. + tuple[list[tuple], list[tuple]]: 匹配成功的位置对 + ``(matched_before, matched_after)``。 Examples: - > before = [(0, 2), (0, 0), (1, 0), (2, 4), (7, 19)] - > after = [(7, 9), (0, 3), (0, 1), (1, 1), (2, 5)] - > match_movable(before, after) + >>> before = [(0, 2), (0, 0), (1, 0), (2, 4), (7, 19)] + >>> after = [(7, 9), (0, 3), (0, 1), (1, 1), (2, 5)] + >>> match_movable(before, [], after, []) ([(0, 2), (0, 0), (1, 0), (2, 4)], [(0, 3), (0, 1), (1, 1), (2, 5)]) """ base_weight = -10000 diff --git a/module/map_detection/detector.py b/module/map_detection/detector.py index e6d9b01f9..a3040e2bf 100644 --- a/module/map_detection/detector.py +++ b/module/map_detection/detector.py @@ -1,3 +1,6 @@ +"""地图检测器封装模块。提供 MapDetector 类作为地图检测的统一入口, +整合单应性变换(Homography)和透视检测(Perspective)两种后端。""" + import numpy as np from module.config.config import AzurLaneConfig diff --git a/module/map_detection/detector_example.py b/module/map_detection/detector_example.py index 1f14de548..267671663 100644 --- a/module/map_detection/detector_example.py +++ b/module/map_detection/detector_example.py @@ -1,3 +1,6 @@ +"""地图检测后端示例模块。展示如何实现自定义地图检测后端, +定义 load/detect/set_backend 等接口供 MapDetector 调用。""" + import numpy as np from module.config.config import AzurLaneConfig diff --git a/module/map_detection/grid.py b/module/map_detection/grid.py index 4d4e961ee..352bc1488 100644 --- a/module/map_detection/grid.py +++ b/module/map_detection/grid.py @@ -1,3 +1,6 @@ +"""地图网格单元模块。定义 Grid 类,组合 GridInfo(网格属性)和 GridPredictor(网格预测), +表示地图中的单个网格及其几何信息。""" + from module.base.decorator import cached_property from module.map_detection.grid_info import GridInfo from module.map_detection.grid_predictor import GridPredictor diff --git a/module/map_detection/grid_info.py b/module/map_detection/grid_info.py index 75392d755..5a409e9c2 100644 --- a/module/map_detection/grid_info.py +++ b/module/map_detection/grid_info.py @@ -1,3 +1,6 @@ +"""网格静态信息模块。定义 GridInfo 类,存储地图网格的固定属性(陆地、海洋、出生点、 +敌人刷新点等),数据来源于碧蓝航线 WIKI。""" + from module.base.utils import location2node diff --git a/module/map_detection/grid_predictor.py b/module/map_detection/grid_predictor.py index f00a8d11f..338b0b2b9 100644 --- a/module/map_detection/grid_predictor.py +++ b/module/map_detection/grid_predictor.py @@ -1,3 +1,6 @@ +"""网格预测模块。定义 GridPredictor 类,通过截图图像和四角坐标进行网格内容识别, +包括敌人检测、舰队检测、素材匹配等。""" + from module.base.utils import * from module.config.config import AzurLaneConfig from module.exception import ScriptError diff --git a/module/map_detection/homography.py b/module/map_detection/homography.py index 3c3e77216..77203df97 100644 --- a/module/map_detection/homography.py +++ b/module/map_detection/homography.py @@ -1,3 +1,6 @@ +"""单应性变换模块。通过特征点匹配计算截图与地图模板之间的单应性矩阵, +用于确定截图在地图中的位置和视角变换。""" + import time import numpy as np @@ -130,7 +133,7 @@ def find_homography(self, size, src_pts, overflow=True): overflow (bool): True 获取完整变换图像,False 仅获取有效区域。 """ self.homo_storage = (size, [(x, y) for x, y in np.round(src_pts, 3)]) - logger.attr('homo_storage', self.homo_storage) + logger.attr('单应存储', self.homo_storage) # 生成透视变换数据 src_pts = np.array(src_pts) - self.config.DETECTING_AREA[:2] @@ -208,11 +211,11 @@ def detect(self, image): # 日志输出 time_cost = round(time.time() - start_time, 3) - logger.info('%ss %s edge_lines: %s hori, %s vert' % ( + logger.info('[地图-单应性] %ss %s 边缘线: %s 水平, %s 垂直' % ( float2str(time_cost), '_' if self.lower_edge else ' ', self._map_edge_count[1], self._map_edge_count[0]) ) - logger.info('Edges: %s%s%s homo_loca: %s' % ( + logger.info('[地图-单应性] 边缘: %s%s%s 单应位置: %s' % ( '/' if self.left_edge else ' ', '_' if self.upper_edge else ' ', '\\' if self.right_edge else ' ', point2str(*self.homo_loca, length=3)) ) @@ -249,7 +252,7 @@ def search_tile_center(self, image, threshold_good=0.9, threshold=0.8, encourage message = 'bad match' # print(self.homo_loca % self.config.HOMO_TILE) - logger.attr_align('tile_center', f'{float2str(similarity)} ({message})') + logger.attr_align('瓦片中心', f'{float2str(similarity)} ({message})') return message != 'bad match' def search_tile_corner(self, image, threshold=0.8, encourage=1.0): @@ -284,7 +287,7 @@ def search_tile_corner(self, image, threshold=0.8, encourage=1.0): message = 'bad match' # print(self.homo_loca % self.config.HOMO_TILE) - logger.attr_align('tile_corner', f'{float2str(similarity)} ({message})') + logger.attr_align('瓦片角点', f'{float2str(similarity)} ({message})') return message != 'bad match' def search_tile_rectangle(self, image, threshold=10, encourage=5.1, close_kernel=(5, 10, 15, 20, 25)): @@ -328,7 +331,7 @@ def search_tile_rectangle(self, image, threshold=10, encourage=5.1, close_kernel message = 'bad match' # print(self.homo_loca % self.config.HOMO_TILE) - logger.attr_align('tile_rectangle', f'{len(location)} rectangles ({message})') + logger.attr_align('瓦片矩形', f'{len(location)} 个矩形 ({message})') return message != 'bad match' def detect_edges(self, image, hough_th=120, theta_th=0.005, edge_th=9): diff --git a/module/map_detection/os_grid.py b/module/map_detection/os_grid.py index 69284451d..9b86ff1db 100644 --- a/module/map_detection/os_grid.py +++ b/module/map_detection/os_grid.py @@ -1,3 +1,6 @@ +"""大世界网格模块。定义 OSGridInfo 和 OSGrid 类,扩展基础网格以支持大世界特有的 +网格属性(敌人、资源、问号、盟友等)和雷达扫描检测。""" + from module.base.utils import * from module.map_detection.grid import Grid, GridInfo, GridPredictor from module.map_detection.utils_assets import ASSETS diff --git a/module/map_detection/perspective.py b/module/map_detection/perspective.py index 552e5a7ea..e6e1b1a30 100644 --- a/module/map_detection/perspective.py +++ b/module/map_detection/perspective.py @@ -1,3 +1,6 @@ +"""透视检测模块。通过检测网格线的消失点和边缘,确定地图的透视变换参数, +用于校正地图视角并识别网格边界。""" + import time import warnings @@ -121,8 +124,8 @@ def load(self, image): self.vanish_point = optimize.brute(self._vanish_point_value, self.config.VANISH_POINT_RANGE) distance_point_x = optimize.brute(self._distant_point_value, self.config.DISTANCE_POINT_X_RANGE)[0] self.distant_point = (distance_point_x, self.vanish_point[1]) - logger.attr_align('vanish_point', point2str(*self.vanish_point, length=5)) - logger.attr_align('distant_point', point2str(*self.distant_point, length=5)) + logger.attr_align('灭点', point2str(*self.vanish_point, length=5)) + logger.attr_align('远点', point2str(*self.distant_point, length=5)) if np.linalg.norm(np.subtract(self.vanish_point, self.distant_point)) < 10: raise MapDetectionError('Vanish point and distant point too close') @@ -149,11 +152,11 @@ def load(self, image): # Log time_cost = round(time.time() - start_time, 3) - logger.info('%ss %s Horizontal: %s (%s inner, %s edge)' % ( + logger.info('[地图-透视] %ss %s 水平: %s (%s 内部, %s 边缘)' % ( float2str(time_cost), '_' if self.lower_edge else ' ', len(self.horizontal), len(horizontal), len(edge_h)) ) - logger.info('Edges: %s%s%s Vertical: %s (%s inner, %s edge)' % ( + logger.info('[地图-透视] 边缘: %s%s%s 垂直: %s (%s 内部, %s 边缘)' % ( '/' if self.left_edge else ' ', '_' if self.upper_edge else ' ', '\\' if self.right_edge else ' ', len(self.vertical), len(vertical), len(edge_v)) ) @@ -360,8 +363,8 @@ def coincident_point_value(point): diff = np.max([mid_diff_range[0] - coincident_point[1], coincident_point[1] - mid_diff_range[1]]) if diff > 0: - logger.info('%s coincident point unexpected: %s' % ( - 'Horizontal' if is_horizontal else 'Vertical', + logger.info('[地图-透视] %s 重合点异常: %s' % ( + '水平' if is_horizontal else '垂直', str(coincident_point))) # 检测区域的边界 diff --git a/module/map_detection/utils.py b/module/map_detection/utils.py index 45dc014ab..aa05f3fa5 100644 --- a/module/map_detection/utils.py +++ b/module/map_detection/utils.py @@ -1,3 +1,13 @@ +"""地图检测工具函数。 + +提供地图网格检测中使用的几何计算工具,包括: +- Points 类:二维点集的操作(排序、变换、拟合) +- 坐标转换:梯形区域到矩形区域的转换 +- 矩形操作:区域填充、裁剪等 + +这些工具被透视检测(Perspective)和网格检测(Grid)模块广泛使用。 +""" + import numpy as np from scipy import optimize @@ -5,6 +15,16 @@ class Points: + """二维点集操作类。 + + 封装 numpy 数组,提供点集的便捷操作方法。 + 支持迭代、索引、长度查询等基本操作。 + + Attributes: + points (np.ndarray): 形状为 (N, 2) 的点集数组。 + x (np.ndarray): 所有点的 x 坐标数组。 + y (np.ndarray): 所有点的 y 坐标数组。 + """ def __init__(self, points): if points is None or len(points) == 0: self._bool = False diff --git a/module/map_detection/utils_assets.py b/module/map_detection/utils_assets.py index 82a7b684f..212e40c2d 100644 --- a/module/map_detection/utils_assets.py +++ b/module/map_detection/utils_assets.py @@ -1,3 +1,6 @@ +"""地图检测资源加载模块。预加载地图检测所需的蒙版图像和模板资源, +包括 UI 蒙版、网格中心/角落模板等。""" + import cv2 import numpy as np diff --git a/module/map_detection/view.py b/module/map_detection/view.py index ad6a4e4f2..e2df3553a 100644 --- a/module/map_detection/view.py +++ b/module/map_detection/view.py @@ -1,3 +1,6 @@ +"""地图视图模块。定义 View 类继承 MapDetector,管理地图中所有网格的集合, +提供网格查询、遍历、舰队位置计算和滑动偏移等功能。""" + import collections import time @@ -148,7 +151,7 @@ def predict_swipe(self, prev, with_current_fleet=True, with_sea_grids=True): tuple[int]: 偏移量 (x, y)。无法预测时返回 None。 Log: - Map swipe predict: (2, 0) (0.023s, current fleet match) + Map swipe predict: (2, 0) (0.023s, 当前舰队匹配) """ start_time = time.time() offset = np.subtract(self.center_loca, prev.center_loca) @@ -168,8 +171,8 @@ def predict_swipe(self, prev, with_current_fleet=True, with_sea_grids=True): diff = np.subtract(current_fleet[0].location, previous_fleet[0].location) - offset # print(current_fleet[0].location, previous_fleet[0].location, offset, diff) diff = tuple(diff.tolist()) - logger.info(f'Map swipe predict: {diff} ({float2str(time.time() - start_time) + "s"}' - f', current fleet match)') + logger.info(f'[地图检测-视图] 地图滑动预测: {diff} ({float2str(time.time() - start_time) + "s"}' + f', 当前舰队匹配)') return diff if with_sea_grids: @@ -187,11 +190,11 @@ def predict_swipe(self, prev, with_current_fleet=True, with_sea_grids=True): # print(diff) if len(diff) == 1 \ or len(diff) >= 2 and diff[0][1] > diff[1][1]: - logger.info(f'Map swipe predict: {diff[0][0]} ' - f'({float2str(time.time() - start_time) + "s"}, {diff[0][1]} matches)') + logger.info(f'[地图检测-视图] 地图滑动预测: {diff[0][0]} ' + f'({float2str(time.time() - start_time) + "s"}, {diff[0][1]} 次匹配)') return diff[0][0] # 无法预测 - logger.info(f'Map swipe predict: None ' - f'({float2str(time.time() - start_time) + "s"}, no match)') + logger.info(f'[地图检测-视图] 地图滑动预测: 无 ' + f'({float2str(time.time() - start_time) + "s"}, 无匹配)') return None diff --git a/module/memory_profiler.py b/module/memory_profiler.py index 5c8180155..6fd9d304c 100644 --- a/module/memory_profiler.py +++ b/module/memory_profiler.py @@ -1,3 +1,10 @@ +""" +内存性能分析器。 + +后台轻量级内存采样工具,通过独立的滚动日志记录内存诊断信息。 +使用 tracemalloc 追踪 Python 内存分配,RSS/USS 捕获原生库内存占用。 +""" + import atexit import gc import logging diff --git a/module/meowfficer/assets.py b/module/meowfficer/assets.py index 5bedca480..a4d042445 100644 --- a/module/meowfficer/assets.py +++ b/module/meowfficer/assets.py @@ -1,3 +1,5 @@ +"""指挥喵模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/meowfficer/base.py b/module/meowfficer/base.py index 305be63f9..16874cfa3 100644 --- a/module/meowfficer/base.py +++ b/module/meowfficer/base.py @@ -1,3 +1,7 @@ +"""指挥喵模块基类,提供指挥喵界面的等待和基础交互方法。 +处理界面加载等待、信息弹窗关闭和每日重置时间计算。 +""" + from module.base.timer import Timer from module.combat.assets import GET_ITEMS_1 from module.config.utils import get_server_next_update @@ -83,7 +87,7 @@ def meow_menu_close(self, skip_first_screenshot=True): in: MEOWFFICER_FORT_CHECK, MEOWFFICER_BUY, MEOWFFICER_TRAIN_START, etc out: page_meowfficer """ - logger.hr('Meowfficer menu close') + logger.hr('指挥喵-菜单关闭') click_timer = Timer(3) while 1: if skip_first_screenshot: diff --git a/module/meowfficer/buy.py b/module/meowfficer/buy.py index d8494b28f..2b14d3dd8 100644 --- a/module/meowfficer/buy.py +++ b/module/meowfficer/buy.py @@ -1,3 +1,18 @@ +"""指挥喵购买模块。 + +处理指挥喵猫箱的购买操作,包括: +- 普通购买:根据配置的购买数量和金币余额购买猫箱 +- 溢出购买:当金币超过阈值时自动购买猫箱以避免资源浪费 + +购买机制说明: +- 每日购买上限:15 个猫箱 +- 每个猫箱价格:1500 金币 +- 首次购买免费(每日第一个猫箱不消耗金币) +- 购买数量支持 OCR 识别剩余次数和金币余额 + +配置项前缀:`Meowfficer_BuyAmount`、`Meowfficer_OverflowBuyThreshold` +""" + from module.combat.assets import GET_ITEMS_1 from module.logger import logger from module.meowfficer.assets import * @@ -13,6 +28,15 @@ class MeowfficerBuy(MeowfficerBase): + """指挥喵购买处理器。 + + 负责指挥喵猫箱的购买操作,支持普通购买和金币溢出购买两种模式。 + + Attributes: + config.Meowfficer_BuyAmount (int): 每日计划购买的猫箱数量。 + config.Meowfficer_OverflowBuyThreshold (int): 金币溢出购买阈值, + 金币超过此值时自动购买猫箱。 + """ def meow_choose(self, count) -> bool: """ Pages: @@ -26,29 +50,29 @@ def meow_choose(self, count) -> bool: bool: If success. """ remain, bought, total = MEOWFFICER.ocr(self.device.image) - logger.attr('Meowfficer_remain', remain) + logger.attr('指挥喵剩余次数', remain) - # Check buy status + # 检查购买状态 if total != BUY_MAX: - logger.warning(f'Invalid meowfficer buy limit: {total}, revise to {BUY_MAX}') + logger.warning(f'[指挥喵-购买] 无效的购买上限: {total},修正为 {BUY_MAX}') total = BUY_MAX bought = total - remain if bought > 0: if bought >= count: - logger.info(f'Already bought {bought} today, stopped') + logger.info(f'[指挥喵-购买] 今天已购买 {bought} 个,停止') return False else: count -= bought - logger.info(f'Already bought {bought} today, only need to buy {count} more') + logger.info(f'[指挥喵-购买] 今天已购买 {bought} 个,还需要购买 {count} 个') - # Check coins + # 检查金币 coins = MEOWFFICER_COINS.ocr(self.device.image) if (coins < BUY_PRIZE) and (remain < total): - logger.info('Not enough coins to buy one, stopped') + logger.info('[指挥喵-购买] 金币不足以购买一个,停止') return False elif (count - int(remain == total)) * BUY_PRIZE > coins: count = coins // BUY_PRIZE + int(remain == total) - logger.info(f'Current coins only enough to buy {count}') + logger.info(f'[指挥喵-购买] 当前金币只够购买 {count} 个') self.meow_enter(MEOWFFICER_BUY_ENTER, check_button=MEOWFFICER_BUY) self.ui_ensure_index(count, letter=MEOWFFICER_CHOOSE, prev_button=MEOWFFICER_BUY_PREV, @@ -63,7 +87,7 @@ def meow_confirm(self, skip_first_screenshot=True) -> None: """ # Here uses a simple click, to avoid clicking MEOWFFICER_BUY multiple times. # Retry logic is in meow_buy() - logger.hr('Meow confirm') + logger.hr('确认购买') executed = False with self.stat.new( genre="meowfficer_buy", @@ -108,7 +132,7 @@ def meow_buy(self) -> bool: in: page_meowfficer out: page_meowfficer """ - logger.hr('Meowfficer buy', level=1) + logger.hr('指挥喵购买', level=1) for _ in range(3): if self.meow_choose(count=self.config.Meowfficer_BuyAmount): @@ -116,7 +140,7 @@ def meow_buy(self) -> bool: else: return True - logger.warning('Too many trial in meowfficer buy, stopped.') + logger.warning('[指挥喵-购买] 尝试次数过多,停止') return False def meow_overflow_buy(self, overflow_coins): @@ -134,29 +158,29 @@ def meow_overflow_buy(self, overflow_coins): in: page_meowfficer out: page_meowfficer """ - logger.hr('Meowfficer overflow buy', level=1) + logger.hr('指挥喵溢出购买', level=1) # OCR识别剩余购买次数 remain, bought, total = MEOWFFICER.ocr(self.device.image) - logger.attr('Meowfficer_remain', remain) - logger.attr('Meowfficer_bought', bought) + logger.attr('指挥喵剩余次数', remain) + logger.attr('指挥喵已购买次数', bought) # 每日限制检查 if total != BUY_MAX: - logger.warning(f'Invalid meowfficer buy limit: {total}, revise to {BUY_MAX}') + logger.warning(f'[指挥喵-溢出] 无效的购买上限: {total},修正为 {BUY_MAX}') total = BUY_MAX bought = total - remain if bought >= BUY_MAX: - logger.info(f'Already bought {bought} today, reached daily limit, skip') + logger.info(f'[指挥喵-溢出] 今天已购买 {bought} 个,达到每日上限,跳过') return # OCR识别金币 coins = MEOWFFICER_COINS.ocr(self.device.image) - logger.attr('Meowfficer_coins', coins) + logger.attr('指挥喵金币', coins) if coins <= overflow_coins: - logger.info(f'Coins {coins} <= threshold {overflow_coins}, skip') + logger.info(f'[指挥喵-溢出] 金币 {coins} <= 阈值 {overflow_coins},跳过') return # 计算溢出购买数量 @@ -172,17 +196,17 @@ def meow_overflow_buy(self, overflow_coins): affordable = coins // BUY_PRIZE + free if count > affordable: count = affordable - logger.info(f'Coins only enough for {count} meowfficers') + logger.info(f'[指挥喵-溢出] 金币只够购买 {count} 个指挥喵') if count <= 0: - logger.info('No meowfficer to buy, skip') + logger.info('[指挥喵-溢出] 没有指挥喵可购买,跳过') return - logger.info(f'Overflow buy count: {count} (overflow_count={overflow_count}, today_left={today_left})') + logger.info(f'[指挥喵-溢出] 溢出购买数量: {count} (溢出计算={overflow_count}, 今日剩余={today_left})') # 执行购买 # 传入总共需要达到的数量(已买 + 还需买),meow_choose 会自动计算差额 if self.meow_choose(count=count + bought): self.meow_confirm() else: - logger.info('Meowfficer overflow buy skipped by meow_choose') + logger.info('[指挥喵-溢出] 溢出购买被 meow_choose 跳过') diff --git a/module/meowfficer/collect.py b/module/meowfficer/collect.py index 2fd930dc7..16b2359d4 100644 --- a/module/meowfficer/collect.py +++ b/module/meowfficer/collect.py @@ -1,3 +1,19 @@ +"""指挥喵收集模块。 + +处理指挥喵训练完成后的收集操作,包括: +- 检测并收集已训练完成的指挥喵(单个或全部) +- 处理指挥喵获取界面的各种弹窗和过渡动画 +- 检测指挥喵是否拥有特殊天赋(金色/紫色品质专属) +- 金色指挥喵的锁定/解锁处理(防止被误用作强化材料) + +特殊天赋检测机制: +- 检查天赋网格中的图标,通过颜色分析区分普通天赋和特殊天赋 +- 屏幕可能发生随机左移,通过 `MEOWFFICER_SHIFT_DETECT` 检测并适配 +- 支持天赋截图记录(通过 `DropRecord_MeowfficerTalent` 配置) + +配置项前缀:`MeowfficerTrain_*`、`DropRecord_*` +""" + from module.base.button import ButtonGrid from module.base.timer import Timer from module.logger import logger @@ -28,6 +44,15 @@ class MeowfficerCollect(MeowfficerBase): + """指挥喵收集处理器。 + + 负责从训练完成界面收集已训练的指挥喵,并根据品质和天赋决定是否保留。 + + Attributes: + config.MeowfficerTrain_RetainTalentedGold (bool): 是否保留有特殊天赋的金色指挥喵。 + config.MeowfficerTrain_RetainTalentedPurple (bool): 是否保留有特殊天赋的紫色指挥喵。 + config.DropRecord_MeowfficerTalent (str): 指挥喵天赋截图记录模式。 + """ def _meow_detect_shift(self, skip_first_screenshot=True): """ Serves as innate wait mechanism for loading @@ -112,7 +137,7 @@ def _meow_is_special_talented(self, drop=None): bool """ # Wait for complete load before examining talents - logger.info('Wait complete load and examine base talents') + logger.info('[指挥喵-收集] 等待加载完成并检查基础天赋') special_talent = False grid = MEOWFFICER_TALENT_GRID_2 if self._meow_detect_shift() else MEOWFFICER_TALENT_GRID_1 @@ -137,8 +162,8 @@ def _meow_is_special_talented(self, drop=None): self._meow_talent_cap_handle(btn, drop) special_talent = True - log_insert = 'Found' if special_talent else 'No' - logger.info(f'{log_insert} special talent abilities in meowfficer') + log_insert = '发现' if special_talent else '未发现' + logger.info(f'[指挥喵-收集] {log_insert}指挥喵拥有特殊天赋') return special_talent def _meow_skip_lock(self): @@ -199,7 +224,7 @@ def _meow_skip_popup_after_locking(self, skip_first_screenshot=True): break # accidentally exited get queue if self.appear(MEOWFFICER_TRAIN_START, offset=(20, 20)): - logger.info('_meow_skip_popup_after_locking exits at MEOWFFICER_TRAIN_START') + logger.info('[指挥喵-收集] 锁定后弹窗处理意外退出至 MEOWFFICER_TRAIN_START') break if self.appear(MEOWFFICER_APPLY_UNLOCK, offset=(40, 40), interval=3): @@ -264,7 +289,7 @@ def meow_get(self, skip_first_screenshot=True): continue count += 1 - logger.attr('Meow_get', count) + logger.attr('[指挥喵-收集] 获取次数', count) with self.stat.new( genre="meowfficer_talent", method=self.config.DropRecord_MeowfficerTalent @@ -312,15 +337,15 @@ def meow_collect(self, collect_all=True): Returns: bool: whether collected or not """ - logger.hr('Meowfficer collect', level=2) + logger.hr('指挥喵收集', level=2) if self.appear(MEOWFFICER_TRAIN_COMPLETE, offset=(20, 20)): # Today is Sunday, finish all else get just one if collect_all: - logger.info('Collect all trained meowfficers') + logger.info('收集所有训练完成的指挥喵') button = MEOWFFICER_TRAIN_FINISH_ALL else: - logger.info('Collect single trained meowfficer') + logger.info('收集单个训练完成的指挥喵') button = MEOWFFICER_TRAIN_COMPLETE self.ui_click(button, check_button=MEOWFFICER_GET_CHECK, additional=self.handle_meow_popup_dismiss, diff --git a/module/meowfficer/enhance.py b/module/meowfficer/enhance.py index 3855ab1b7..d3a6889c9 100644 --- a/module/meowfficer/enhance.py +++ b/module/meowfficer/enhance.py @@ -1,3 +1,21 @@ +"""指挥喵强化模块。 + +处理指挥喵强化(喂养)相关的所有操作,包括: +- 选择目标指挥喵进行强化 +- 扫描可用的喂养材料(多余的指挥喵) +- 确认并执行强化操作 +- 强化完成后自动提升索引至下一个指挥喵 + +强化机制说明: +- 消耗低等级指挥喵作为材料,为目标指挥喵提供经验值 +- 每次强化最多可使用 10 个喂养材料 +- 强化需要消耗金币(每次 1000) +- 目标指挥喵最高可升至 30 级 +- 喂养材料的最大等级可通过 `MeowfficerTrain_MaxFeedLevel` 配置 + +配置项前缀:`MeowfficerTrain_*` +""" + from module.base.button import ButtonGrid from module.base.timer import Timer from module.logger import logger @@ -21,6 +39,11 @@ class MeowfficerLevelOcr(Digit): + """指挥喵等级 OCR 识别器。 + + 针对指挥喵等级显示的特殊 OCR 处理,移除等级标识字符(L、V) + 和小数点,以提高数字识别精度。 + """ def __init__(self, buttons, lang='azur_lane', letter=(255, 255, 255), threshold=128, alphabet='0123456789IDSLV', name=None): super().__init__(buttons, lang=lang, letter=letter, threshold=threshold, alphabet=alphabet, name=name) @@ -34,6 +57,15 @@ def after_process(self, result): class MeowfficerEnhance(MeowfficerBase): + """指挥喵强化处理器。 + + 负责指挥喵强化(喂养)的完整流程: + 选择目标指挥喵 -> 扫描可用材料 -> 选择材料 -> 确认强化 -> 循环直至资源耗尽。 + + Attributes: + config.MeowfficerTrain_EnhanceIndex (int): 目标指挥喵在网格中的位置索引(1~12)。 + config.MeowfficerTrain_MaxFeedLevel (int): 喂养材料的最大等级限制(1~30)。 + """ def _meow_select(self, skip_first_screenshot=True): """ Select the target meowfficer in the @@ -98,7 +130,7 @@ def meow_feed_scan(self): reset_max_feed_level = 30 if -1 != reset_max_feed_level: - logger.warning(f"Condition '1 <= MeowfficerTrain_MaxFeedLevel <= 30' needs to be satisfied, " + logger.warning(f"[指挥喵-强化] 条件 '1 <= MeowfficerTrain_MaxFeedLevel <= 30' 需要满足, " f'now MeowfficerTrain_MaxFeedLevel is {self.config.MeowfficerTrain_MaxFeedLevel}, ' f'reset to {reset_max_feed_level}') self.config.MeowfficerTrain_MaxFeedLevel = reset_max_feed_level @@ -131,7 +163,7 @@ def meow_feed_scan(self): # button is clickable clickable.append(button) - logger.info(f'Total feed material found: {len(clickable)}') + logger.info(f'[指挥喵-强化] 找到强化材料总数: {len(clickable)}') return clickable def meow_feed_select(self): @@ -184,11 +216,11 @@ def meow_feed_select(self): # Use current to pass appropriate button for ui_click # route back to MEOWFFICER_ENHANCE if current: - logger.info(f'Confirm selected feed material, total: {current} / 10') + logger.info(f'[指挥喵-强化] 确认选择的强化材料, 总数: {current} / 10') self.ui_click(MEOWFFICER_FEED_CONFIRM, check_button=MEOWFFICER_ENHANCE_CONFIRM, offset=(20, 20), skip_first_screenshot=True) else: - logger.info('Lack of feed material to complete enhancement, cancelling') + logger.info('[指挥喵-强化] 强化材料不足,取消强化') self.ui_click(MEOWFFICER_FEED_CANCEL, check_button=MEOWFFICER_ENHANCE_CONFIRM, offset=(10, 10), skip_first_screenshot=True) return current @@ -225,7 +257,7 @@ def meow_feed_enter(self, skip_first_screenshot=True): if confirm_timer.reached(): return True if click_count >= 3: - logger.warning('Unable to enter meowfficer feed, ' + logger.warning('[指挥喵-强化] 无法进入指挥喵喂养, ' 'probably because the meowfficer to enhance has reached LV.30') return False @@ -286,7 +318,7 @@ def meow_enhance_enter(self, skip_first_screenshot=True): if self.appear(MEOWFFICER_FEED_ENTER, offset=(20, 20)): return True if count > 3: - logger.warning('Too many click on MEOWFFICER_ENHANCE_ENTER, meowfficer may in battle') + logger.warning('[指挥喵-强化] MEOWFFICER_ENHANCE_ENTER 点击次数过多,指挥喵可能在战斗中') return False if self.appear_then_click(MEOWFFICER_ENHANCE_ENTER, offset=(20, 20), interval=3): @@ -308,7 +340,7 @@ def _meow_get_level(self): """ level = OCR_MEOWFFICER_ENHANCE_LEVEL.ocr(self.device.image) if level > 30: - logger.warning(f'Invalid meowfficer level: {level}') + logger.warning(f'[指挥喵-强化] 无效的指挥喵等级: {level}') return level def _meow_enhance(self): @@ -324,20 +356,20 @@ def _meow_enhance(self): in: page_meowfficer out: page_meowfficer """ - logger.hr('Meowfficer enhance', level=1) - logger.attr('MeowfficerTrain_EnhanceIndex', self.config.MeowfficerTrain_EnhanceIndex) + logger.hr('指挥喵强化', level=1) + logger.attr('强化索引', self.config.MeowfficerTrain_EnhanceIndex) # Base Cases # - Config at least > 0 but less than or equal to 12 # - Coins at least > 1000 if not (1 <= self.config.MeowfficerTrain_EnhanceIndex <= 12): - logger.warning(f'Meowfficer_EnhanceIndex={self.config.MeowfficerTrain_EnhanceIndex} ' + logger.warning(f'[指挥喵-强化] 强化索引={self.config.MeowfficerTrain_EnhanceIndex} ' f'is out of bounds. Please limit to 1~12, skip') return 'invalid' coins = MEOWFFICER_COINS.ocr(self.device.image) if coins < 1000: - logger.info(f'Coins ({coins}) < 1000. Not enough coins to complete ' + logger.info(f'[指挥喵-强化] 物资 ({coins}) < 1000, 物资不足无法完成 ' f'enhancement, skip') return 'coin_limit' @@ -347,7 +379,7 @@ def _meow_enhance(self): self._meow_select() if self._meow_get_level() >= 30: - logger.info('Current meowfficer is already leveled max') + logger.info('[指挥喵-强化] 当前指挥喵已满级') return 'leveled_max' # Transition to MEOWFFICER_FEED after @@ -367,7 +399,7 @@ def _meow_enhance(self): # - Confirm Enhancement # - Check remaining coins after enhancement while 1: - logger.hr('Enhance once', level=2) + logger.hr('强化一次', level=2) if not self.meow_feed_enter(): # Exit back into page_meowfficer self.ui_click(MEOWFFICER_GOTO_DORMMENU, check_button=MEOWFFICER_ENHANCE_ENTER, @@ -382,7 +414,7 @@ def _meow_enhance(self): coins = MEOWFFICER_COINS.ocr(self.device.image) if coins < 1000: - logger.info(f'Remaining coins ({coins}) < 1000. Not enough coins for next ' + logger.info(f'[指挥喵-强化] 剩余物资 ({coins}) < 1000, 物资不足以进行下次 ' f'enhancement, skip') break @@ -405,9 +437,9 @@ def meow_enhance(self): # Only for 'leveled_max' if self.config.MeowfficerTrain_EnhanceIndex < 12: self.config.MeowfficerTrain_EnhanceIndex += 1 - logger.info(f'Increase MeowfficerTrain_EnhanceIndex to {self.config.MeowfficerTrain_EnhanceIndex}') + logger.info(f'[指挥喵-强化] 强化索引增加至 {self.config.MeowfficerTrain_EnhanceIndex}') continue else: - logger.warning('The 12th meowfficer reached LV.30, disable MeowfficerTrain') + logger.warning('[指挥喵-强化] 第12只指挥喵达到30级,禁用指挥喵训练') self.config.MeowfficerTrain_Enable = False break diff --git a/module/meowfficer/fort.py b/module/meowfficer/fort.py index 595c248b6..f10e5416e 100644 --- a/module/meowfficer/fort.py +++ b/module/meowfficer/fort.py @@ -1,3 +1,7 @@ +"""指挥喵猫窝处理器,执行猫窝中的日常互动获取经验值。 +包括完成日常任务和收起已放置的指挥喵。 +""" + from module.base.timer import Timer from module.combat.assets import GET_ITEMS_1 from module.logger import logger @@ -75,7 +79,7 @@ def meow_fort(self): # Check for fort red notification if not self.appear(MEOWFFICER_FORT_RED_DOT): return False - logger.hr('Meowfficer fort', level=1) + logger.hr('指挥喵-小屋', level=1) # Enter MEOWFFICER_FORT window self.meow_enter(MEOWFFICER_FORT_ENTER, check_button=MEOWFFICER_FORT_CHECK) diff --git a/module/meowfficer/meowfficer.py b/module/meowfficer/meowfficer.py index e70b24670..00a3dff58 100644 --- a/module/meowfficer/meowfficer.py +++ b/module/meowfficer/meowfficer.py @@ -1,3 +1,17 @@ +"""指挥喵任务调度模块。 + +作为指挥喵系统(Meowfficer)的顶层任务入口,负责编排指挥喵相关的所有子任务: +- 指挥喵购买(Buy):消耗金币购买指挥喵猫箱 +- 指挥喵要塞(Fort):执行要塞日常任务 +- 指挥喵训练(Train):收集已训练的指挥喵并排队训练新猫箱 +- 指挥喵强化(Enhance):消耗多余指挥喵为目标指挥喵提供经验值 + +根据配置决定执行哪些子任务,并安排下次执行的调度时间。 +训练模式下,强化操作在每周日或无缝模式下自动执行。 + +配置项前缀:`Meowfficer_*`、`MeowfficerTrain_*` +""" + from module.meowfficer.buy import MeowfficerBuy from module.meowfficer.fort import MeowfficerFort from module.meowfficer.train import MeowfficerTrain @@ -5,6 +19,18 @@ class RewardMeowfficer(MeowfficerBuy, MeowfficerFort, MeowfficerTrain): + """指挥喵任务调度器。 + + 继承购买、要塞、训练三个子模块,按配置依次执行对应的指挥喵操作。 + 所有子任务完成后,根据是否启用训练功能安排不同的延迟时间: + - 训练模式:延迟 2.5~3.5 小时(等待训练完成) + - 非训练模式:延迟至服务器更新时间 + + 继承关系: + MeowfficerBuy: 指挥喵购买功能 + MeowfficerFort: 指挥喵要塞功能 + MeowfficerTrain: 指挥喵训练功能 + """ def run(self): """ Execute buy, enhance, train, and fort operations diff --git a/module/meowfficer/train.py b/module/meowfficer/train.py index ccdda576c..e0a5d91e7 100644 --- a/module/meowfficer/train.py +++ b/module/meowfficer/train.py @@ -1,3 +1,22 @@ +"""指挥喵训练模块。 + +处理指挥喵训练相关的所有操作,包括: +- 训练队列管理:自动将指挥喵猫箱排队进行训练 +- 收集已训练完成的指挥喵 +- 根据库存情况选择升序或降序排队策略 + +排队策略说明: +- 升序排队(ascending=True):优先使用普通猫箱(蓝/紫),保留金色猫箱 + - 当普通猫箱库存 > 20 时使用,避免金色猫箱被优先消耗 +- 降序排队(ascending=False):优先使用稀有猫箱(金/紫/蓝),系统自动分配 + +训练模式(`MeowfficerTrain_Mode`): +- `seamlessly`(无缝模式):训练与强化无缝衔接,使用降序排队 +- 默认模式:周日收集全部,工作日收集单个 + +配置项前缀:`MeowfficerTrain_*` +""" + from copy import deepcopy from module.base.button import ButtonGrid @@ -22,6 +41,19 @@ class MeowfficerTrain(MeowfficerCollect, MeowfficerEnhance): + """指挥喵训练处理器。 + + 管理指挥喵的训练流程,包括猫箱排队、训练队列管理和已训练指挥喵的收集。 + 支持两种排队模式:系统自动排队(降序)和手动排队(升序)。 + + 继承关系: + MeowfficerCollect: 指挥喵收集功能(收集已训练的指挥喵) + MeowfficerEnhance: 指挥喵强化功能(消耗多余指挥喵提供经验) + + Attributes: + _box_count (list[int]): 三种猫箱(蓝/紫/金)的数量列表, + 在 `meow_train()` 中通过 OCR 读取。 + """ _box_count = [0, 0, 0] def _meow_queue_enter(self, skip_first_screenshot=True): @@ -58,7 +90,7 @@ def _meow_queue_enter(self, skip_first_screenshot=True): if self.appear(MEOWFFICER_TRAIN_FILL_QUEUE, offset=(20, 20)): return True if self.info_bar_count(): - logger.info('No more slots to train, exit') + logger.info('[指挥喵-训练] 没有更多训练栏位,退出') return False def _meow_nqueue(self, skip_first_screenshot=True): @@ -121,7 +153,7 @@ def _meow_rqueue(self): # Loop as needed to queue boxes appropriately for i, j in ((0, 2), (1, 1)): - logger.attr(f'Meowfficer_box_count_rqueue_during (index {i})', local_count) + logger.attr(f'训练中猫箱数量 (索引 {i})', local_count) count = local_count[i] - remain if count < 0: self.device.multi_click(buttons[j], remain + count) @@ -132,7 +164,7 @@ def _meow_rqueue(self): local_count[i] -= remain break - logger.attr('Meowfficer_box_count_rqueue_done', local_count) + logger.attr('训练完成猫箱数量', local_count) self.device.sleep((0.3, 0.5)) self.device.screenshot() @@ -154,7 +186,7 @@ def meow_queue(self, ascending=True): in: MEOWFFICER_TRAIN out: MEOWFFICER_TRAIN """ - logger.hr('Meowfficer queue', level=1) + logger.hr('指挥喵队列', level=1) # Either can remain in same window or # enter the queuing window if not self._meow_queue_enter(): @@ -166,7 +198,7 @@ def meow_queue(self, ascending=True): # Check remains if sum(self._box_count) <= 0: - logger.info('No more meowfficer boxes to train') + logger.info('[指挥喵-训练] 没有更多猫箱可训练') return # Choose appropriate queue func based on @@ -175,14 +207,14 @@ def meow_queue(self, ascending=True): # - > 20, high stock; queue common boxes first if ascending: if common_sum > 20: - logger.info('Queue in ascending order (Blue > Purple > Gold)') + logger.info('[指挥喵-训练] 升序队列 (蓝 > 紫 > 金)') self._meow_rqueue() else: - logger.info('Low stock of common cat boxes') - logger.info('Queue in descending order (Gold > Purple > Blue)') + logger.info('[指挥喵-训练] 普通猫箱库存不足') + logger.info('[指挥喵-训练] 降序队列 (金 > 紫 > 蓝)') self._meow_nqueue() else: - logger.info('Queue in descending order (Gold > Purple > Blue)') + logger.info('[指挥喵-训练] 降序队列 (金 > 紫 > 蓝)') self._meow_nqueue() def meow_train(self): @@ -194,16 +226,16 @@ def meow_train(self): in: page_meowfficer out: page_meowfficer """ - logger.hr('Meowfficer train', level=1) + logger.hr('指挥喵训练', level=1) # Retrieve capacity to determine whether able to collect current, remain, total = MEOWFFICER_CAPACITY.ocr(self.device.image) - logger.attr('Meowfficer_capacity_remain', remain) + logger.attr('剩余容量', remain) # Read box count, utilized in other helper funcs self._box_count = MEOWFFICER_BOX_COUNT.ocr(self.device.image) - logger.attr('MeowfficerTrain_Mode', self.config.MeowfficerTrain_Mode) + logger.attr('训练模式', self.config.MeowfficerTrain_Mode) collected = False if self.config.MeowfficerTrain_Mode == 'seamlessly': # Enter diff --git a/module/meta_reward/assets.py b/module/meta_reward/assets.py index 2b7285014..0a68c3e51 100644 --- a/module/meta_reward/assets.py +++ b/module/meta_reward/assets.py @@ -1,3 +1,5 @@ +"""META奖励模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/meta_reward/meta_reward.py b/module/meta_reward/meta_reward.py index 07d9ac63d..fbed33062 100644 --- a/module/meta_reward/meta_reward.py +++ b/module/meta_reward/meta_reward.py @@ -1,3 +1,33 @@ +""" +META 奖励收取模块。 + +自动化 META 系统中的奖励收取流程,包括余烬信标(Beacon)奖励 +和档案(Dossier)奖励两种类型。 + +主要功能: + - 检测并收取 META 同步奖励(sync reward):累积 META 点数达到 100% + 后获取 META 舰船 + - 检测并收取 META 信标奖励(beacon reward):余烬信标战斗后的奖励 + - 检测并收取档案奖励(dossier reward):已完成的旧 META 档案奖励 + - 处理新 META 舰船锁定确认、物品获取弹窗 + +META 系统机制: + - 余烬信标:玩家通过余烬信标战斗累积点数,达到 100% 可获取 META 舰船 + - 同步(Sync):累积点数的过程,完成后可获取舰船 + - 档案(Dossier):已完成的旧 META 活动,可领取遗留奖励 + +继承关系: + - BeaconReward: 继承 Combat + UI,处理信标奖励和同步奖励 + - DossierReward: 继承 Combat + UI,处理档案奖励 + - MetaReward: 继承 BeaconReward + DossierReward,统一入口 + +服务器支持:CN、EN、JP(TW 不支持) + +Pages: + META 页面:page_meta + 档案 META 页面:dossier meta page +""" + from module.base.timer import Timer from module.combat.combat import Combat from module.logger import logger @@ -8,6 +38,24 @@ class BeaconReward(Combat, UI): + """ + 余烬信标奖励处理器。 + + 处理 META 页面中的信标战斗奖励和同步奖励收取。 + 同步奖励是玩家累积 META 点数达到 100% 后获得 META 舰船的过程; + 信标奖励是余烬信标战斗后的常规奖励。 + + 核心流程: + 1. 导航至 META 页面,等待页面加载完成 + 2. 检测同步奖励红点,收取同步奖励(获取 META 舰船) + 3. 检测信标奖励红点,收取信标奖励(物品/经验) + + 属性: + 无额外实例属性 + + 配置项: + OpsiAshBeacon_AutoCollectShip: 是否自动收取 META 舰船 + """ def meta_reward_notice_appear(self): """ Returns: @@ -33,7 +81,7 @@ def meta_reward_receive(self, skip_first_screenshot=True): in: page_meta or REWARD_CHECK out: REWARD_CHECK """ - logger.hr('Meta reward receive', level=1) + logger.hr('领取META奖励', level=1) confirm_timer = Timer(1, count=3).start() received = False while 1: @@ -67,7 +115,7 @@ def meta_reward_receive(self, skip_first_screenshot=True): confirm_timer.reset() continue - logger.info(f'Meta reward receive finished, received={received}') + logger.info(f'[META-奖励] META奖励领取完成, 领取={received}') return received def meta_sync_notice_appear(self, interval=0): @@ -100,7 +148,7 @@ def meta_sync_receive(self, skip_first_screenshot=True): out: SYNC_ENTER if meta ship synced < 100% REWARD_ENTER if meta ship synced >= 100% """ - logger.hr('Meta sync receive', level=1) + logger.hr('Meta同步领取', level=1) received = False while 1: if skip_first_screenshot: @@ -111,7 +159,7 @@ def meta_sync_receive(self, skip_first_screenshot=True): # End # Sync progress >= 100% if self.appear(REWARD_ENTER, offset=(20, 20)): - logger.info('meta_sync_receive ends at REWARD_ENTER') + logger.info('[META-同步] 同步领取在REWARD_ENTER结束') break if self.config.SERVER == 'en': @@ -125,7 +173,7 @@ def meta_sync_receive(self, skip_first_screenshot=True): else: if self.appear(SYNC_ENTER, offset=(20, 20)): if not self.meta_sync_notice_appear(): - logger.info('meta_sync_receive ends at SYNC_ENTER') + logger.info('[META-同步] 同步领取在SYNC_ENTER结束') break # Click @@ -139,7 +187,7 @@ def meta_sync_receive(self, skip_first_screenshot=True): received = True continue if self.appear(SYNC_REWARD_NOTICE, threshold=30, interval=3): - logger.info(f'sync reward notice appear -> {SYNC_ENTER}') + logger.info(f'[META-同步] 同步奖励通知出现 -> {SYNC_ENTER}') self.device.click(SYNC_ENTER) received = True continue @@ -151,11 +199,11 @@ def meta_sync_receive(self, skip_first_screenshot=True): else: # Collect ship manually, just skip SYNC_TAP if self.appear(SYNC_TAP, offset=(20, 20)): - logger.info(f"Skip ship collection due to auto collect ship is disabled") + logger.info(f"[META-奖励] 跳过舰船收集,因为自动收集舰船已禁用") received = False break - logger.info(f'Meta sync receive finished, received={received}') + logger.info(f'[META-同步] META同步领取完成, 领取={received}') return received def meta_wait_reward_page(self, skip_first_screenshot=True): @@ -170,37 +218,37 @@ def meta_wait_reward_page(self, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning(f'meta_wait_reward_page timeout') + logger.warning(f'[META-同步] 等待奖励页面超时') break if self.appear(REWARD_ENTER, offset=(20, 20)): - logger.info(f'meta_wait_reward_page ends at {REWARD_ENTER}') + logger.info(f'[META-同步] 等待奖励页面在 {REWARD_ENTER} 结束') break if self.config.SERVER == 'en': if self.appear(SYNC_ENTER, offset=(20, 20)): - logger.info(f'meta_wait_reward_page ends at {SYNC_ENTER}') + logger.info(f'[META-同步] 等待奖励页面在 {SYNC_ENTER} 结束') break elif self.appear(SYNC_ENTER2, offset=(20, 20)): - logger.info(f'meta_wait_reward_page ends at {SYNC_ENTER2}') + logger.info(f'[META-同步] 等待奖励页面在 {SYNC_ENTER2} 结束') break else: if self.appear(SYNC_ENTER, offset=(20, 20)): - logger.info(f'meta_wait_reward_page ends at {SYNC_ENTER}') + logger.info(f'[META-同步] 等待奖励页面在 {SYNC_ENTER} 结束') break if self.appear(SYNC_TAP, offset=(20, 20)): - logger.info(f'meta_wait_reward_page ends at {SYNC_TAP}') + logger.info(f'[META-同步] 等待奖励页面在 {SYNC_TAP} 结束') break if self.meta_sync_notice_appear(): - logger.info('meta_wait_reward_page ends at sync red dot') + logger.info('[META-同步] 等待奖励页面在同步红点结束') break if self.meta_reward_notice_appear(): - logger.info('meta_wait_reward_page ends at reward red dot') + logger.info('[META-同步] 等待奖励页面在奖励红点结束') break def run(self): if self.config.SERVER in ['cn', 'en', 'jp']: pass else: - logger.info(f'MetaReward is not supported in {self.config.SERVER}, please contact server maintainers') + logger.info(f'[META-同步] MetaReward不支持 {self.config.SERVER} 服务器,请联系服务器维护者') return self.ui_ensure(page_meta) @@ -209,20 +257,31 @@ def run(self): # Sync rewards # "sync" is the period that you gather meta points to 100% and get a meta ship if self.meta_sync_notice_appear(): - logger.info('Found meta sync red dot or sync tap') + logger.info('[META-同步] 找到META同步红点或同步按钮') self.meta_sync_receive() else: - logger.info('No meta sync red dot or sync tap') + logger.info('[META-同步] 未找到META同步红点或同步按钮') # Meta rewards if self.meta_reward_notice_appear(): - logger.info('Found meta reward red dot') + logger.info('[META-同步] 找到META奖励红点') self.meta_reward_receive() else: - logger.info('No meta reward red dot') + logger.info('[META-同步] 未找到META奖励红点') class DossierReward(Combat, UI): + """ + META 档案奖励处理器。 + + 处理已完成的旧 META 档案中遗留的奖励收取。档案是已结束的 + META 活动,玩家可从中领取之前未收取的奖励。 + + 核心流程: + 1. 导航至档案 META 页面 + 2. 检测是否有可收取的档案奖励红点 + 3. 进入奖励界面并逐个收取奖励 + """ def meta_reward_notice_appear(self): """ Returns: @@ -233,10 +292,10 @@ def meta_reward_notice_appear(self): """ self.device.screenshot() if self.appear(DOSSIER_REWARD_RECEIVE, offset=(-40, 10, -10, 40), similarity=0.7): - logger.info('Found dossier reward red dot') + logger.info('[META-同步] 找到档案奖励红点') return True else: - logger.info('No dossier reward red dot') + logger.info('[META-同步] 未找到档案奖励红点') return False def meta_reward_enter(self, skip_first_screenshot=True): @@ -245,7 +304,7 @@ def meta_reward_enter(self, skip_first_screenshot=True): in: dossier meta page out: DOSSIER_REWARD_CHECK """ - logger.info('Dossier reward enter') + logger.info('[META-同步] 进入档案奖励') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -272,7 +331,7 @@ def meta_reward_receive(self, skip_first_screenshot=True): in: DOSSIER_REWARD_CHECK out: DOSSIER_REWARD_CHECK """ - logger.hr('Dossier reward receive', level=1) + logger.hr('档案奖励领取', level=1) confirm_timer = Timer(1, count=3).start() received = False while 1: @@ -305,14 +364,14 @@ def meta_reward_receive(self, skip_first_screenshot=True): else: confirm_timer.reset() - logger.info(f'Dossier reward receive finished, received={received}') + logger.info(f'[META-同步] 档案奖励领取完成, 领取={received}') return received def run(self): if self.config.SERVER in ['cn', 'en', 'jp']: pass else: - logger.info(f'MetaReward is not supported in {self.config.SERVER}, please contact server maintainers') + logger.info(f'[META-同步] MetaReward不支持 {self.config.SERVER} 服务器,请联系服务器维护者') return from module.os_ash.meta import OpsiAshBeacon @@ -323,10 +382,20 @@ def run(self): class MetaReward(BeaconReward, DossierReward): + """ + META 奖励统一入口。 + + 组合 BeaconReward 和 DossierReward,根据 category 参数 + 分发到对应的奖励处理流程。 + + Args: + category (str): 奖励类型,'beacon' 表示余烬信标奖励, + 'dossier' 表示档案奖励 + """ def run(self, category="beacon"): if category == "beacon": BeaconReward(self.config, self.device).run() elif category == "dossier": DossierReward(self.config, self.device).run() else: - logger.info(f'Possible wrong parameter {category}, please contact the developers.') + logger.info(f'[META-同步] 可能的错误参数 {category},请联系开发者') diff --git a/module/minigame/assets.py b/module/minigame/assets.py index a88971560..5c156aec6 100644 --- a/module/minigame/assets.py +++ b/module/minigame/assets.py @@ -1,3 +1,5 @@ +"""小游戏模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/minigame/minigame.py b/module/minigame/minigame.py index 55769f286..978cdc314 100644 --- a/module/minigame/minigame.py +++ b/module/minigame/minigame.py @@ -1,3 +1,31 @@ +""" +小游戏自动化模块。 + +管理学院游戏室(Game Room)中的小游戏自动化流程。 +负责游戏券/代币的收集、小游戏选择、游玩和退出。 + +主要功能: + - OCR 识别当前代币数量 + - 自动收集游戏代币 + - 导航至游戏室主页 + - 选择并游玩特定小游戏(如新年挑战) + - 处理弹窗(代币已满、游戏券不足等) + +代币机制: + - 最大代币上限为 40,OCR 超过 40 时截断 + - 代币数量 <= 30 时尝试自动收集 + - 代币为 0 时结束游玩循环 + - 每局游玩后根据配置决定下次游玩时间 + +依赖关系: + - MinigameRun: 小游戏运行基类,定义选择/游玩/退出模板方法 + - Minigame: 主任务类,组合代币管理、游戏室导航和游玩循环 + +Pages: + 游戏室页面:page_game_room + 学院页面:page_academy +""" + import module.config.server as server from module.combat.assets import GET_ITEMS_1 from module.logger import logger @@ -21,6 +49,22 @@ MINIGAME_SCROLL = Scroll(MINIGAME_SCROLL_AREA, color=(247, 247, 247), name='MINIGAME_SCROLL') class MinigameRun(UI): + """ + 小游戏运行基类。 + + 定义小游戏的通用运行流程模板:导航至游戏列表、选择游戏、 + 投入代币、游玩、退出。具体游戏逻辑由子类实现。 + + 子类需要重写以下方法: + - choose_game(): 从游戏列表中选择目标游戏 + - use_coin(): 投入代币并准备游玩 + - play_game(): 执行游戏的具体操作 + - exit_game(): 退出当前游戏 + - deal_specific_popup(): 处理特定游戏的弹窗 + + 属性: + 无额外属性,所有状态通过方法参数传递 + """ def minigame_run(self, skip_first_screenshot=True): """ @@ -30,10 +74,10 @@ def minigame_run(self, skip_first_screenshot=True): Return: False if unable or unnecessary to play """ - logger.hr('Minigame run', level=1) + logger.hr('[小游戏] 运行', level=1) # page_game_room main_page -> MINIGAME_SCROLL - logger.info("Enter minigame") + logger.info("[小游戏] 进入小游戏") while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -52,14 +96,14 @@ def minigame_run(self, skip_first_screenshot=True): # that won't enter any minigame on the minigame list page continue - logger.info("Choose minigame") + logger.info("[小游戏] 选择小游戏") self.choose_game() # try to add coins, if failed, skip play add_coin_result = self.use_coin() if add_coin_result: - logger.hr("Play minigame", level=2) + logger.hr("[小游戏] 游玩", level=2) self.play_game() - logger.info("Exit minigame") + logger.info("[小游戏] 退出小游戏") self.exit_game() return add_coin_result @@ -109,6 +153,26 @@ def exit_game(self, skip_first_screenshot=True): class Minigame(UI): + """ + 小游戏主任务类。 + + 管理小游戏任务的完整生命周期:从学院页面导航到游戏室, + 收集代币,选择并游玩小游戏,直到代币耗尽或达到游玩上限。 + + 流程概要: + 1. 从任意页面导航至学院 -> 游戏室主页 + 2. OCR 读取代币数量 + 3. 代币 <= 30 时尝试自动收集 + 4. 代币 > 0 时选择小游戏并游玩(最多 10 次) + 5. 代币耗尽后调度下次运行 + + 配置项: + 通过 self.config.task_delay(server_update=True) 调度下次运行 + + Pages: + 任务入口页面:任意页面 + 任务结束页面:page_game_room + """ def get_coin_amount(self, skip_first_screenshot=True): """ @@ -131,7 +195,7 @@ def go_to_main_page(self, skip_first_screenshot=True): in: page_game_room main_page/choose_game_page out: page_game_room main_page """ - logger.info('minigame go_to_main_page') + logger.info('[小游戏] 前往主页') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -219,7 +283,7 @@ def run(self): break # ocr to get coin count and ticket count coin_count = self.get_coin_amount() - logger.info(f"coin count : {coin_count}") + logger.info(f"[小游戏] 硬币数量: {coin_count}") # collect coins if coin_count <= 30 and not coin_collected: coin_collected = True @@ -227,15 +291,15 @@ def run(self): continue # no coin left if coin_count == 0: - logger.info(f"coin count : {coin_count}, finished") + logger.info(f"[小游戏] 硬币数量: {coin_count}, 游玩结束") break - logger.info(f"coin count > 0, spend") + logger.info("[小游戏] 硬币数量 > 0,消费") # specific game logic if minigame_instance is not None and minigame_instance.minigame_run(): play_count += 1 continue elif minigame_instance is None: - logger.error(f"unknown game name {specific_game_name}") + logger.error(f"[小游戏] 未知的游戏名称 {specific_game_name}") break else: break diff --git a/module/minigame/new_year_challenge.py b/module/minigame/new_year_challenge.py index c1397ea4d..9ad586e4f 100644 --- a/module/minigame/new_year_challenge.py +++ b/module/minigame/new_year_challenge.py @@ -1,3 +1,7 @@ +"""新年挑战小游戏自动化模块。 +通过颜色识别按钮状态和 OCR 读取得分与消耗, +自动完成小游戏的循环战斗操作。""" + from module.base.timer import Timer from module.logger import logger from module.minigame.assets import * diff --git a/module/notify/__init__.py b/module/notify/__init__.py index 8a1111480..31c3b523f 100644 --- a/module/notify/__init__.py +++ b/module/notify/__init__.py @@ -1,3 +1,5 @@ +"""通知模块。""" + def handle_notify(*args, **kwargs): """处理推送通知请求。 diff --git a/module/notify/notify.py b/module/notify/notify.py index 3189b7099..47e5f31be 100644 --- a/module/notify/notify.py +++ b/module/notify/notify.py @@ -1,3 +1,13 @@ +"""推送通知(Push Notification)模块。 + +通过 onepush 库将任务执行结果推送到外部渠道(QQ、微信、Telegram 等)。 +支持 YAML 格式的通知配置和 WebUI 本地推送。 + +主要函数: + - handle_notify(): 解析 YAML 配置并通过指定渠道发送推送通知。 + - notify_webui(): 向本地 WebUI 服务发送 HTTP POST 通知。 +""" + import onepush.core import yaml from onepush import get_notifier @@ -29,12 +39,12 @@ def handle_notify(_config: str, **kwargs) -> bool: for item in yaml.safe_load_all(_config): config.update(item) except Exception: - logger.error("Fail to load onepush config, skip sending") + logger.error("加载onepush配置失败,跳过发送") return False try: provider_name: str = config.pop("provider", None) if provider_name is None: - logger.info("No provider specified, skip sending") + logger.info("未指定推送提供者,跳过发送") return False notifier: Provider = get_notifier(provider_name) required: list[str] = notifier.params["required"] @@ -44,7 +54,7 @@ def handle_notify(_config: str, **kwargs) -> bool: for key in required: if key not in config: logger.warning( - f"Notifier {notifier.name} require param '{key}' but not provided" + f"[通知] 推送渠道 {notifier.name} 缺少必需参数 '{key}'" ) if isinstance(notifier, Custom): @@ -67,26 +77,26 @@ def handle_notify(_config: str, **kwargs) -> bool: resp = notifier.notify(**config) if isinstance(resp, Response): if resp.status_code != 200: - logger.warning("Push notify failed!") - logger.warning(f"HTTP Code:{resp.status_code}") + logger.warning("推送通知失败!") + logger.warning(f"[通知] HTTP状态码:{resp.status_code}") return False else: if provider_name.lower() == "gocqhttp": return_data: dict = resp.json() if return_data["status"] == "failed": - logger.warning("Push notify failed!") + logger.warning("推送通知失败!") logger.warning( f"Return message:{return_data['wording']}") return False except OnePushException: - logger.error("Push notify failed") + logger.error("推送通知失败") return False except Exception as e: # 不打印完整异常栈,避免暴露变量信息 logger.error(e) return False - logger.info("Push notify success") + logger.info("推送通知成功") return True diff --git a/module/ocr/al_ocr.py b/module/ocr/al_ocr.py index c17ee616e..20ff3fd37 100644 --- a/module/ocr/al_ocr.py +++ b/module/ocr/al_ocr.py @@ -1,3 +1,28 @@ +"""AlOcr 文字识别引擎。 + +基于 RapidOCR 框架的多后端 OCR 系统,支持: +- ONNX Runtime 推理(默认),支持 DirectML (Windows GPU) 和 CoreML (macOS ANE) 加速 +- NCNN 推理,推理速度更快但模型覆盖较窄 +- Windows ML 设备选择,精确控制 GPU/CPU 推理设备 +- 自定义 CNN-CTC 英文识别模型(900k 参数),专为碧蓝航线优化 + +模型按语言区分: +- azur_lane:英文数字识别(游戏 UI 中的数字、等级、时间等) +- azur_lane_jp:日文服务器专用识别模型 +- cn:中文识别(中+英混合) +- jp:日文识别 +- tw:繁体中文识别 + +工作线程模型: +- OCR 推理在专用后台线程 (AlOcrQueue) 中执行,避免阻塞主循环 +- 模型使用懒加载策略,首次使用时才初始化 +- 模型缓存按 (名称, 后端, 设备, 版本) 组合键管理 + +检测模型: +- 使用 PP-OCRv6 tiny 检测模型定位文本区域 +- 检测+识别流水线在 ncnn 和 ONNX 后端有不同实现 +""" + import os import queue import threading @@ -16,7 +41,20 @@ def handle_ocr_error(e): - logger.critical(f"Failed to load OCR dependencies: {e}") + """处理 OCR 依赖加载失败的统一错误处理。 + + 打印详细的故障排除指引,包括: + - 安装微软 C++ 运行库 + - 关闭 GPU 加速 + - 获取社区支持 + + Args: + e (Exception): 原始异常。 + + Raises: + RequestHumanTakeover: 始终抛出,需要用户手动干预。 + """ + logger.critical(f"加载OCR依赖失败: {e}") logger.critical( "[OCR] 无法加载 OCR 依赖,请安装微软 C++ 运行库 https://aka.ms/vs/17/release/vc_redist.x64.exe" ) @@ -62,7 +100,12 @@ def handle_ocr_error(e): class RecOnlyOCR(RapidOCR): - """只加载识别模型,跳过 det 和 cls 的 ONNX 模型加载。""" + """只加载识别模型,跳过 det 和 cls 的 ONNX 模型加载。 + + 碧蓝航线的 OCR 场景中,文本位置通常固定(已通过 Button 区域裁剪), + 不需要文本检测模型,仅需识别模型即可。跳过检测模型可节省约 10MB 内存 + 和加载时间。 + """ def _initialize(self, cfg): self.text_score = cfg.Global.text_score @@ -92,7 +135,24 @@ def _initialize(self, cfg): class AlOcrCtcRecOCR: - """900k 参数 CNN-CTC 英文识别模型,直接使用 ONNXRuntime 推理。""" + """900k 参数 CNN-CTC 英文识别模型。 + + 专为碧蓝航线优化的轻量级英文识别模型,直接使用 ONNXRuntime 推理, + 不依赖 RapidOCR 框架。使用 CTC (Connectionist Temporal Classification) + 解码算法进行序列识别。 + + 模型特点: + - 固定输入高度 48px,最大宽度 768px + - 字符集仅包含数字、冒号、斜线和大小写英文字母 + - 支持 DirectML/CoreML GPU 加速 + + Attributes: + model_path (Path): ONNX 模型文件路径。 + device (str): 推理设备('cpu'、'gpu'、'ane')。 + charset (str): 识别字符集。 + blank_id (int): CTC blank token 的索引。 + session: ONNXRuntime 推理会话。 + """ def __init__(self, model_path, device="cpu", allow_vendor_execution_providers=True): try: @@ -457,7 +517,7 @@ def _create_ocr(name): if backend == 'ncnn': if not supports_ncnn_model(name): raise ValueError(f"Unsupported ncnn OCR model: {name}") - logger.info("OCR backend is ncnn, using ncnn-specific recognition model") + logger.info("[OCR] OCR后端为ncnn,使用ncnn专用识别模型") return NcnnRecOCR(name, device=config.ocr_device) else: ocr_device = config.ocr_device @@ -523,7 +583,11 @@ def _get_model(name): class DetOnlyOCR(RapidOCR): - """仅加载 RapidOCR 检测模型,识别部分由 ncnn 处理。""" + """仅加载 RapidOCR 检测模型,识别部分由 ncnn 处理。 + + 在 ncnn 后端模式下,文本检测使用 ONNX 的 PP-OCRv6 tiny 检测模型, + 而文本识别使用 ncnn 的识别模型。此类封装了这种混合模式的检测端。 + """ def _initialize(self, cfg): self.text_score = cfg.Global.text_score @@ -645,6 +709,21 @@ def reset_ocr_model(): class AlOcr: + """统一的 OCR 识别接口。 + + 封装了 ONNX 和 ncnn 两种后端的识别和检测功能,提供一致的 API。 + 所有 OCR 推理操作在专用后台线程中执行,避免阻塞主事件循环。 + + 支持的操作: + - ocr(): 单行文本识别(已裁剪的文本图像) + - det(): 文本检测 + 识别(完整图像,返回带位置坐标的结果) + - ocr_for_single_lines(): 批量单行文本识别 + + Attributes: + name (str): 模型名称,如 'azur_lane'、'cn'、'jp'、'tw'。 + model: 识别模型实例(懒加载)。 + _det_model: 检测模型实例(懒加载)。 + """ def __init__(self, **kwargs): self.model = None self.name = kwargs.get("name", "en") @@ -716,7 +795,7 @@ def _save_debug_image(self, img, result): pass except Exception as e: # 不应因调试图片保存失败而崩溃主进程 - logger.warning(f"Failed to save OCR debug image: {e}") + logger.warning(f"保存OCR调试图像失败: {e}") def _ocr_direct(self, img_fp): logger.debug(f"[VERBOSE] AlOcr.ocr: Ensure loaded...") @@ -731,7 +810,7 @@ def _ocr_direct(self, img_fp): self._save_debug_image(img_fp, txt) return txt except Exception as e: - logger.error(f"AlOcr.ocr exception: {e}") + logger.error(f"AlOcr.ocr异常: {e}") raise def ocr(self, img_fp): @@ -782,7 +861,7 @@ def _det_direct(self, img_fp): return results return [] except Exception as e: - logger.error(f"AlOcr.det exception: {e}") + logger.error(f"AlOcr.det异常: {e}") raise def _save_det_debug(self, img, results): diff --git a/module/ocr/models.py b/module/ocr/models.py index 27d3b90d1..c044b2a2b 100644 --- a/module/ocr/models.py +++ b/module/ocr/models.py @@ -1,30 +1,64 @@ +"""OCR 模型实例的懒加载管理。 + +提供全局共享的 OCR 模型实例集合 `OCR_MODEL`,通过 `cached_property` +实现按需加载。每个模型对应一种语言的识别能力: + +- azur_lane: 碧蓝航线英文数字识别(游戏 UI 中的等级、时间、数量等) +- azur_lane_jp: 日文服务器专用识别模型 +- ppocr_v6: 通用 PP-OCRv6 识别模型 +- cnocr: 中文识别(中+英混合文本) +- jp: 日文识别 +- tw: 繁体中文识别 + +使用示例: + >>> from module.ocr.models import OCR_MODEL + >>> text = OCR_MODEL.azur_lane.ocr(image) + +模型在首次访问时自动初始化,后续访问复用已加载的实例。 +通过 `del_cached_property` 可释放模型以节省内存。 +""" + from module.base.decorator import cached_property from module.ocr.al_ocr import AlOcr class OcrModel: + """OCR 模型集合,提供各语言识别模型的懒加载访问。 + + 每个属性返回一个 AlOcr 实例,首次访问时初始化模型。 + 模型实例在进程生命周期内保持,直到被显式释放。 + """ + @cached_property def azur_lane(self): + """碧蓝航线英文数字识别模型。用于游戏 UI 中的数字、等级、时间等。""" return AlOcr(name='azur_lane') @cached_property def azur_lane_jp(self): + """日文服务器专用识别模型。""" return AlOcr(name='azur_lane_jp') @cached_property def ppocr_v6(self): + """通用 PP-OCRv6 识别模型。""" return AlOcr(name='ppocr_v6') @cached_property def cnocr(self): + """中文识别模型(中+英混合文本)。""" return AlOcr(name='cn') @cached_property def jp(self): + """日文识别模型。""" return AlOcr(name='jp') @cached_property def tw(self): + """繁体中文识别模型。""" return AlOcr(name='tw') + +# 全局共享的 OCR 模型实例,所有模块通过此对象访问 OCR 功能 OCR_MODEL = OcrModel() diff --git a/module/ocr/ncnn_ocr.py b/module/ocr/ncnn_ocr.py index 6d1b1b888..7cf658cba 100644 --- a/module/ocr/ncnn_ocr.py +++ b/module/ocr/ncnn_ocr.py @@ -1,3 +1,24 @@ +"""NCNN OCR 识别后端。 + +基于 NCNN 推理框架的文本识别模型,比 ONNX 后端推理速度更快。 +NCNN 是一个为移动端优化的高性能神经网络推理框架, +特别适合 CPU 推理场景。 + +模型规格: +- 输入:3 通道 48x320 的 RGB 图像 +- 输出:CTC 解码的文本序列 +- 模型文件:.param(网络结构)+ .bin(权重数据)+ 字典文件 + +支持的模型: +- azur_lane: 英文数字识别(碧蓝航线专用) +- azur_lane_jp: 日文服务器专用 +- cn: 中文识别 +- jp: 日文识别 +- tw: 繁体中文识别 + +注意:ncnn 后端不支持文本检测,需要配合 ONNX 检测模型使用。 +""" + import atexit import math import threading @@ -15,8 +36,10 @@ from module.logger import logger +# 项目根目录和 NCNN 模型目录 REPO_ROOT = Path(__file__).resolve().parents[2] MODEL_ROOT = REPO_ROOT / "bin/ocr_models/ncnn" +# 模型输入尺寸:3 通道 x 48 高 x 320 宽 REC_IMAGE_SHAPE = (3, 48, 320) INPUT_NAME = "in0" OUTPUT_NAME = "out0" @@ -291,7 +314,7 @@ def _create_net(self) -> None: backend = f"{backend} ({gpu_name})" else: backend = "CPU" - logger.info(f"Loaded ncnn OCR model '{self.spec.name}' on {backend}") + logger.info(f"[OCR-NCNN] 已加载ncnnOCR模型 '{self.spec.name}' 在 {backend}") @staticmethod def _check_return(value, op: str, path: Path) -> None: diff --git a/module/ocr/ocr.py b/module/ocr/ocr.py index 082ad01e4..3af47eb13 100644 --- a/module/ocr/ocr.py +++ b/module/ocr/ocr.py @@ -1,3 +1,6 @@ +"""OCR 文字识别模块。提供 Ocr、Digit、DigitCounter、Duration 等识别器类, +支持多种 OCR 后端(ONNX/NCNN/远程服务器),用于识别游戏中的文字和数字。""" + import time from datetime import timedelta from typing import TYPE_CHECKING @@ -161,7 +164,7 @@ def after_process(self, result): result = int(result) if result else 0 if self.SHOW_REVISE_WARNING: if str(result) != prev: - logger.warning(f'OCR {self.name}: Result "{prev}" is revised to "{result}"') + logger.warning(f'[OCR] {self.name}: 结果 "{prev}" 修正为 "{result}"') return result @@ -203,7 +206,7 @@ def ocr(self, image, direct_ocr=False): current = min(current, total) return current, total - current, total else: - logger.warning(f'Unexpected ocr result: {result_list}') + logger.warning(f'[OCR] 意外的OCR结果: {result_list}') return 0, 0, 0 @@ -255,7 +258,7 @@ def parse_time(string): result = [int(s) for s in result.groups()] return timedelta(hours=result[0], minutes=result[1], seconds=result[2]) else: - logger.warning(f'Invalid duration: {string}') + logger.warning(f'[OCR] 无效的时长: {string}') return timedelta(hours=0, minutes=0, seconds=0) diff --git a/module/ocr/rpc.py b/module/ocr/rpc.py index 3f3b02319..520d9e85c 100644 --- a/module/ocr/rpc.py +++ b/module/ocr/rpc.py @@ -31,23 +31,23 @@ def init(cls, address="127.0.0.1:22268"): """ import zerorpc - logger.info(f"Connecting to OCR server {address}") + logger.info(f"连接OCR服务器 {address}") cls.client = zerorpc.Client(timeout=5) cls.client.connect(f"tcp://{address}") try: cls.client.hello() - logger.info("Successfully connected to OCR server") + logger.info("成功连接OCR服务器") except Exception: cls.online = False - logger.warning("Ocr server not running") + logger.warning("服务器未运行") @classmethod def close(cls): """关闭 RPC 客户端连接。""" if cls.client is not None: - logger.info('Disconnect to OCR server') + logger.info('断开OCR服务器') cls.client.close() - logger.info('Successfully disconnected to OCR server') + logger.info('成功断开') cls.client = None def __init__(self, lang) -> None: @@ -372,9 +372,9 @@ def debug(self, lang, img_list): try: server.bind(f"tcp://*:{port}") except zmq.error.ZMQError: - logger.error(f"Ocr server cannot bind on port {port}") + logger.error(f"[OCR-RPC] OCR 服务器无法绑定端口 {port}") return - logger.info(f"Ocr server listen on port {port}") + logger.info(f"[OCR-RPC] 服务器监听端口 {port}") server.run() diff --git a/module/ocr/windows_ml.py b/module/ocr/windows_ml.py index 7be1009e3..4903a7897 100644 --- a/module/ocr/windows_ml.py +++ b/module/ocr/windows_ml.py @@ -1,3 +1,24 @@ +"""Windows ML ONNX Runtime 设备选择模块。 + +在 Windows 平台上为 ONNX Runtime 推理会话选择最优的执行提供程序 (Execution Provider)。 +支持的 EP 优先级(从高到低): + +1. DirectML (DmlExecutionProvider): 通用 GPU 加速,支持所有 Windows GPU +2. QNN (QNNExecutionProvider): 高通 NPU 加速(特定硬件) +3. OpenVINO (OpenVINOExecutionProvider): Intel 硬件加速 +4. CUDA (CUDAExecutionProvider): NVIDIA GPU 加速 +5. CPU (CPUExecutionProvider): 兜底方案 + +设备选择逻辑: +- 根据用户配置的设备偏好('gpu'、'cpu'、'npu')选择 EP +- 自动检测 AMD 集成显卡并排除不兼容的 EP +- 通过 GPU 显存大小区分独显和集显 +- 使用线程锁确保 EP 初始化的线程安全性 + +核心函数 create_onnx_session() 被 al_ocr.py 调用, +为 OCR 模型创建优化的推理会话。 +""" + import os import re import threading @@ -5,6 +26,7 @@ from module.logger import logger +# 执行提供程序常量 QNN_EP = "QNNExecutionProvider" OPENVINO_EP = "OpenVINOExecutionProvider" DML_EP = "DmlExecutionProvider" diff --git a/module/os/assets.py b/module/os/assets.py index 1ff405589..643df27c3 100644 --- a/module/os/assets.py +++ b/module/os/assets.py @@ -1,3 +1,5 @@ +"""大世界模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/os/camera.py b/module/os/camera.py index 9187c1739..be38bd24e 100644 --- a/module/os/camera.py +++ b/module/os/camera.py @@ -1,3 +1,16 @@ +"""大世界相机控制模块。 + +管理大世界(Operation Siren)地图的相机移动和视图更新。 + +大世界的相机系统与主线战役不同: +- 使用 Homography(单应性变换)而非 Perspective(透视检测) +- 固定的存储参数用于网格检测 +- 滑动区域和边界与主线战役不同 +- 使用 OSGrid 而非 Grid 进行网格检测 + +继承自 OSMapOperation 和 Camera,组合了大世界地图操作和相机控制能力。 +""" + import cv2 import numpy as np @@ -14,6 +27,14 @@ class OSCamera(OSMapOperation, Camera): + """大世界相机控制器。 + + 管理大世界地图的相机位置、视图更新和坐标转换。 + + Attributes: + radar (Radner): 雷达对象,用于检测大世界中的目标。 + fleet_current (tuple): 当前舰队位置。 + """ radar: Radar fleet_current: tuple @@ -83,7 +104,7 @@ def _get_map_outside_button(self): edge = self.view.backend.right_edge area = (edge.get_x(360), 360, 1280, 560) else: - logger.info('No left edge or right edge') + logger.info('[大世界-相机] 没有左边缘或右边缘') self.ensure_edge_insight() continue @@ -101,7 +122,7 @@ def update_os(self): self.view.load(self.device.image) except (MapDetectionError, AttributeError, cv2.error) as e: logger.warning(e) - logger.warning('Assuming camera is focused on grid center') + logger.warning('[大世界-相机] 假设摄像机聚焦在格子中心') def empty(*args, **kwargs): pass @@ -137,26 +158,26 @@ def convert_radar_to_local(self, location): if fleets.count == 1: center = fleets[0].location elif fleets.count > 1: - logger.warning(f'Convert radar to local, but found multiple current fleets: {fleets}') + logger.warning(f'[大世界-相机] 雷达转换到本地时发现多个当前舰队: {fleets}') fleets = fleets.sort_by_camera_distance(self.view.center_loca) center = fleets[0].location logger.warning( - f'Assuming the nearest fleet to camera canter is current fleet: {location2node(center)}') + f'假设距离摄像机中心最近的舰队为当前舰队: {location2node(center)}') else: - logger.warning(f'Convert radar to local, but current fleet not found. ' - f'Assuming camera center is current fleet: {location2node(self.view.center_loca)}') + logger.warning(f'[大世界-相机] 雷达转换到本地时未找到当前舰队, ' + f'假设摄像机中心为当前舰队: {location2node(self.view.center_loca)}') center = self.view.center_loca try: local = self.view[np.add(location, center)] except KeyError: - logger.warning(f'Convert radar to local, but target grid not in local view. ' - f'Assuming camera center is current fleet: {location2node(self.view.center_loca)}') + logger.warning(f'[大世界-相机] 雷达转换到本地时目标格子不在本地视野中, ' + f'假设摄像机中心为当前舰队: {location2node(self.view.center_loca)}') center = self.view.center_loca local = self.view[np.add(location, center)] logger.info( - f'Radar {location} -> Local {location2node(local.location)} ' - f'(fleet={location2node(center)})' + f'[大世界-相机] 雷达 {location} -> 本地 {location2node(local.location)} ' + f'(舰队={location2node(center)})' ) return local diff --git a/module/os/config.py b/module/os/config.py index 98f145e1e..e6facb3f4 100644 --- a/module/os/config.py +++ b/module/os/config.py @@ -1,6 +1,32 @@ +"""大世界(Operation Siren)配置类。 + +定义大世界地图操作所需的配置参数,包括: +- 地图检测参数(透视检测、网格识别) +- 滑动参数(滑动倍率、最小距离) +- 战斗相关配置(塞壬检测、情绪管理等) +- 故事选项配置 + +大世界的配置参数与主线战役不同,需要单独定义。 +OSConfig 被 OperationSiren 等大世界模块使用。 +""" + + class OSConfig: - """ - Configs for Operation Siren + """大世界配置参数类。 + + 定义大世界地图操作所需的所有配置参数。 + 这些参数覆盖了地图检测、滑动控制和战斗管理的默认值。 + + Attributes: + STORY_OPTION (int): 剧情选项,-2 表示自动选择。 + MAP_FOCUS_ENEMY_AFTER_BATTLE (bool): 战斗后是否聚焦到敌人位置。 + MAP_HAS_SIREN (bool): 地图是否有塞壬敌人。 + MAP_HAS_FLEET_STEP (bool): 地图是否有步数限制。 + IGNORE_LOW_EMOTION_WARN (bool): 是否忽略低情绪警告。 + MAP_GRID_CENTER_TOLERANCE (float): 网格中心对齐容差。 + MAP_SWIPE_DROP (float): 最小滑动距离阈值。 + MAP_SWIPE_MULTIPLY (tuple): 滑动距离倍率。 + DETECTION_BACKEND (str): 检测后端('perspective' 或 'homography')。 """ STORY_OPTION = -2 diff --git a/module/os/dock_mixin.py b/module/os/dock_mixin.py index 77deff310..7453d8a2f 100644 --- a/module/os/dock_mixin.py +++ b/module/os/dock_mixin.py @@ -1,3 +1,15 @@ +"""大世界船坞操作 Mixin。 + +提供大世界舰队配队时的船坞界面操作功能。 +通过网格索引选择指定位置的舰船,支持 14 个网格位置(两排各 7 个)。 + +继承自 Dock 类,复用船坞界面的基础操作。 + +网格布局: + 0-6: 第一排(从左到右) + 7-13: 第二排(从左到右) +""" + from module.logger import logger from module.retire.dock import CARD_GRIDS, Dock diff --git a/module/os/fleet.py b/module/os/fleet.py index d42b5f19d..9084b30a1 100644 --- a/module/os/fleet.py +++ b/module/os/fleet.py @@ -1,12 +1,30 @@ -# 此文件专门负责大世界(Operation Siren)模式下的舰队基础控制。 -# 涵盖了大世界特有的移动逻辑、血量检测、港口定位以及根据战斗状态切换编队的底层指令。 +""" +大世界舰队控制模块。 + +负责大世界(Operation Siren)模式下的舰队基础控制,涵盖大世界特有的 +移动逻辑、血量检测、港口定位以及根据战斗状态切换编队的底层指令。 + +主要类: + OSFleet: 大世界舰队主控类,整合摄像机、战斗、地图舰队和余烬系统。 + BossFleet: Boss 舰队数据类,记录舰队索引和待命位置。 + PercentageOcr: 百分比 OCR 识别器,用于塞壬要塞清理进度。 + +术语: + 大世界 (Operation Siren / OS): 碧蓝航线的高难度 PVE 模式。 + 塞壬 (Siren): 大世界中的敌对势力。 + 余烬 (Ash/Ember): 大世界中的特殊系统,可通过信标触发战斗。 + 行动力 (Action Point / AP): 进入海域需要消耗的资源。 + 港口 (Port): 碧蓝航线的补给/修理据点。 + 适应性 (Adaptability): 大世界中的特殊属性加成。 + 净化装置 (Purification Device): 大世界中的特殊装置。 + 安全区 (Safe Zone): 已完全清理的海域。 + 危险区 (Danger Zone): 未完全清理的海域。 +""" import re import inflection import numpy as np -# 此文件专门负责大世界(Operation Siren)模式下的舰队基础控制。 -# 涵盖了大世界特有的移动逻辑、血量检测、港口定位以及根据战斗状态切换编队的底层指令。 from module.base.button import Button, ButtonGrid from module.base.filter import Filter from module.base.timer import Timer @@ -35,6 +53,18 @@ def limit_walk(location, step=3): + """限制舰队单次移动步数,防止越界。 + + 将移动向量限制在曼哈顿距离不超过 step 的范围内, + 优先保留 y 方向的移动量。 + + Args: + location (tuple[int, int]): 目标移动向量 (x, y)。 + step (int): 最大步数。默认为 3。 + + Returns: + tuple[int, int]: 限制后的移动向量。 + """ x, y = location if abs(x) > 0: x = min(abs(x), step - abs(y)) * x // abs(x) @@ -42,7 +72,23 @@ def limit_walk(location, step=3): class BossFleet: + """Boss 战斗用舰队数据类。 + + 用于舰队筛选器中代表一支可参与 Boss 战的编队。 + 每支 BossFleet 会分配一个待命位置 (standby_loca), + 在 Boss 战间歇时舰队会移动到该位置等待。 + + Attributes: + fleet_index (int): 舰队编号(1~4)。 + fleet (str): 舰队编号的字符串形式。 + standby_loca (tuple[int, int]): 待命位置坐标, + 用于 Boss 战间的站位安排。 + """ def __init__(self, fleet_index): + """ + Args: + fleet_index (int): 舰队编号(1~4)。 + """ self.fleet_index = fleet_index self.fleet = str(fleet_index) self.standby_loca = (0, 0) @@ -57,11 +103,31 @@ def __eq__(self, other): class PercentageOcr(Ocr): + """百分比 OCR 识别器。 + + 用于识别塞壬要塞清理进度等百分比数值。 + 对输入图像进行上下白色填充以提高识别准确率。 + + Attributes: + 继承自 Ocr 的所有属性。 + """ def __init__(self, *args, **kwargs): + """初始化百分比 OCR,强制使用 azur_lane 语言模型。""" kwargs['lang'] = 'azur_lane' super().__init__(*args, **kwargs) def pre_process(self, image): + """对 OCR 输入图像进行预处理。 + + 调用父类预处理后,在图像上下各填充 2 像素的白色边框, + 以提高百分比数字的识别率。 + + Args: + image (np.ndarray): 原始裁剪图像。 + + Returns: + np.ndarray: 预处理后的图像。 + """ image = super().pre_process(image) image = np.pad(image, ((2, 2), (0, 0)), mode='constant', constant_values=255) return image @@ -73,7 +139,30 @@ def pre_process(self, image): class OSFleet(OSCamera, Combat, Fleet, OSAsh): + """大世界舰队主控类。 + + 整合摄像机控制 (OSCamera)、战斗系统 (Combat)、地图舰队 (Fleet) + 和余烬系统 (OSAsh),提供大世界模式下完整的舰队操控能力。 + + 负责: + - 舰队在地图上的移动和导航 + - 血量检测和修理判断 + - 港口定位和前往 + - Boss 战斗编队切换 + - 深渊海域 (Abyssal) 处理 + - 塞壬要塞进度检测 + + Attributes: + need_repair (list[bool]): 每个舰位是否需要修理(显示扳手图标)。 + _os_map_event_handled (bool): 地图事件处理标志,用于伏击/神秘格子判断。 + """ def _goto(self, location, expected=''): + """移动舰队到指定位置,同时更新雷达和处理余烬信标攻击。 + + Args: + location (tuple[int, int]): 目标格子坐标。 + expected (str): 预期到达后的状态,如 'combat'、'mystery'。 + """ super()._goto(location, expected) self.predict_radar() self.map.show() @@ -116,12 +205,18 @@ def find_current_fleet(self): @property def _walk_sight(self): + """获取大世界行走视野范围。 + + Returns: + tuple[int, int, int, int]: 视野边界 (x1, y1, x2, y2)。 + """ sight = (-4, -1, 3, 2) return sight _os_map_event_handled = False def ambush_color_initial(self): + """重置大世界地图事件处理标志。""" self._os_map_event_handled = False def handle_ambush(self): @@ -162,6 +257,13 @@ def _get_goto_expected(grid): return '' def _hp_grid(self): + """获取舰队血条位置网格。 + + 根据不同服务器的 OS 布局调整血条位置。 + + Returns: + ButtonGrid: 血条按钮网格。 + """ hp_grid = super()._hp_grid() # 六个血条的位置,根据各服务器的 OS 布局 @@ -175,6 +277,11 @@ def _hp_grid(self): return hp_grid def _storage_hp_grid(self): + """获取仓库界面中的血条位置网格。 + + Returns: + ButtonGrid: 仓库界面的血条按钮网格。 + """ return ButtonGrid(origin=(185, 553), delta=(166, 0), button_shape=(99, 4), grid_shape=(6, 1)) def hp_retreat_triggered(self): @@ -194,7 +301,7 @@ def hp_get(self): ship_icon = self._hp_grid().crop((0, -67, 67, 0)) need_repair = [TEMPLATE_EMPTY_HP.match(self.image_crop(button, copy=False)) for button in ship_icon.buttons] self.need_repair = need_repair - logger.attr('Repair icon', need_repair) + logger.attr('维修图标', need_repair) if any(need_repair): for index, repair in enumerate(need_repair): @@ -202,7 +309,7 @@ def hp_get(self): self._hp_has_ship[self.fleet_current_index][index] = True self._hp[self.fleet_current_index][index] = 0 - logger.attr('HP', ' '.join( + logger.attr('血量', ' '.join( [str(int(data * 100)).rjust(3) + '%' if use else '____' for data, use in zip(self.hp, self.hp_has_ship, strict=False)])) @@ -220,8 +327,8 @@ def _storage_hp_get(self): for index, ship in enumerate(has_ship): self._hp_has_ship[self.fleet_current_index][index] = ship self.need_repair = [all(repair) for repair in zip(need_repair, has_ship, strict=False)] - logger.attr('Repair icon', self.need_repair) - logger.attr('HP', ' '.join( + logger.attr('维修图标', self.need_repair) + logger.attr('血量', ' '.join( [str(int(data * 100)).rjust(3) + '%' if use else '____' for data, use in zip(self.hp, self.hp_has_ship, strict=False)])) @@ -240,6 +347,7 @@ def storage_hp_get(self): return self.hp def lv_get(self, after_battle=False): + """获取舰队等级(大世界中为空实现)。""" pass def fleet_low_resolve_appear(self): @@ -277,13 +385,13 @@ def wait_until_camera_stable(self, skip_first_screenshot=True): """ 等待 homo_loca 稳定。DETECTION_BACKEND 必须为 'homography'。 """ - logger.hr('Wait until camera stable') + logger.hr('等待摄像机稳定') record = None confirm_timer = Timer(0.6, count=2).start() for _ in self.loop(skip_first=skip_first_screenshot): self.update_os() current = self.view.backend.homo_loca - logger.attr('homo_loca', current) + logger.attr('单应位置', current) if record is None or (current is not None and np.linalg.norm(np.subtract(current, record)) < 3): if confirm_timer.reached(): break @@ -292,7 +400,7 @@ def wait_until_camera_stable(self, skip_first_screenshot=True): record = current - logger.info('Camera stabled') + logger.info('[大世界-摄像机] 摄像机已稳定') def wait_until_walk_stable(self, confirm_timer=None, skip_first_screenshot=False, walk_out_of_step=True, drop=None): """ @@ -312,7 +420,7 @@ def wait_until_walk_stable(self, confirm_timer=None, skip_first_screenshot=False Raises: MapWalkError: 无法到达目标格子时抛出。 """ - logger.hr('Wait until walk stable') + logger.hr('等待移动稳定') record = None enemy_searching_appear = False self.device.screenshot_interval_set(0.35) @@ -346,13 +454,13 @@ def abyssal_expected_end(): # 两个操作都返回 'story_skip' 事件 # 连续 2 次 story_skip 表示提交了塞壬扫描装置 if clicked_story_count >= 11: - logger.info('Continuous options in story') + logger.info('[大世界-剧情] 剧情中连续选项') self.device.click_record_clear() clicked_story_count = 0 elif event == 'map_get_items': # story_skip -> map_get_items 表示收到了深渊进度奖励 if clicked_story: - logger.info('Got items from story') + logger.info('[大世界-剧情] 从剧情获得物品') self.device.click_record_clear() clicked_story = False clicked_story_count = 0 @@ -428,7 +536,7 @@ def abyssal_expected_end(): if enemy_searching_appear: self.handle_enemy_flashing() self.device.sleep(0.3) - logger.info('Enemy searching appeared.') + logger.info('[大世界-战斗] 敌舰搜索出现') enemy_searching_appear = False confirm_timer.reset() result.add('search') @@ -441,7 +549,7 @@ def abyssal_expected_end(): if self.match_template_color(IN_MAP, offset=(200, 5), threshold=50): self.update_os() current = self.view.backend.homo_loca - logger.attr('homo_loca', current) + logger.attr('单应位置', current) # 已知最大距离为 4.48px,homo_loca 在 (56, 60) 和 (52, 58) 之间 if record is None or (current is not None and np.linalg.norm(np.subtract(current, record)) < 5.5): if confirm_timer.reached(): @@ -453,7 +561,7 @@ def abyssal_expected_end(): confirm_timer.reset() result = '_'.join(result) - logger.info(f'Walk stabled, result: {result}') + logger.info(f'[大世界-移动] 移动已稳定, 结果: {result}') self.device.screenshot_interval_set() return result @@ -473,7 +581,7 @@ def port_goto(self, allow_port_arrive=True): while 1: # 计算目的地 grid = self.radar.port_predict(self.device.image) - logger.info(f'Port route at {grid}') + logger.info(f'[大世界-港口] 港口路径在 {grid}') if grid is None: self.device.screenshot() continue @@ -481,18 +589,18 @@ def port_goto(self, allow_port_arrive=True): radar_arrive = np.linalg.norm(grid) == 0 port_arrive = self.appear(PORT_ENTER, offset=(20, 20)) if allow_port_arrive and port_arrive: - logger.info('Arrive port (port_arrive)') + logger.info('[大世界-港口] 到达港口 (port_arrive)') break elif allow_port_arrive and (not port_arrive and radar_arrive): if confirm_timer.reached(): - logger.warning('Arrive port on radar but port entrance not appear') + logger.warning('[大世界-港口] 雷达上到达港口但港口入口未出现') raise MapWalkError else: - logger.info('Arrive port on radar but port entrance not appear, confirming') + logger.info('[大世界-港口] 雷达上到达港口但港口入口未出现,确认中') self.device.screenshot() continue elif not allow_port_arrive and radar_arrive: - logger.info('Arrive port (radar_arrive)') + logger.info('[大世界-港口] 到达港口 (radar_arrive)') break else: confirm_timer.reset() @@ -518,7 +626,7 @@ def fleet_set(self, index=1, skip_first_screenshot=True): Returns: bool: If switched. """ - logger.hr(f'Fleet set to {index}') + logger.hr(f'舰队设置为 {index}') if self.fleet_selector.ensure_to_be(index): self.wait_until_camera_stable() return True @@ -534,7 +642,7 @@ def storage_fleet_set(self, index=1, skip_first_screenshot=True): Returns: bool: If switched. """ - logger.hr(f'Fleet set to {index}') + logger.hr(f'舰队设置为 {index}') return self.storage_fleet_selector.ensure_to_be(index) def parse_fleet_filter(self): @@ -556,8 +664,19 @@ def parse_fleet_filter(self): return fleets def relative_goto(self, has_fleet_step=False, near_by=False, relative_position=(0, 0), index=0, **kwargs): - logger.hr('Relative goto') - logger.info(f'Relative goto, {dict_to_kv(kwargs)}') + """根据雷达选择的相对位置导航舰队。 + + 更新本地视野后,通过雷达选择目标格子并点击移动。 + + Args: + has_fleet_step (bool): 是否限制舰队移动步数。 + near_by (bool): 是否按距离排序选择最近的格子。 + relative_position (tuple[int, int]): 相对偏移量。 + index (int): 选择第几个匹配的格子。 + **kwargs: 传递给 radar.select() 的筛选条件。 + """ + logger.hr('相对移动') + logger.info(f'[大世界-移动] 相对移动, {dict_to_kv(kwargs)}') # 更新本地视野 # 不截图,复用旧截图 @@ -579,15 +698,23 @@ def relative_goto(self, has_fleet_step=False, near_by=False, relative_position=( grid = self.convert_radar_to_local(grid) self.device.click(grid) else: - logger.info('No position to goto, stop') + logger.info('[大世界-移动] 无目标位置,停止') # 等待到达 # 使用新截图 self.wait_until_walk_stable(confirm_timer=Timer(1.5, count=4), walk_out_of_step=False) def go_month_boss_room(self, is_normal=True): - logger.hr('Goto room entrance') - logger.info(f'Goto room entrance, is_normal={is_normal}') + """导航到月度 Boss 房间入口并进入。 + + 先移动到入口下方 2 格处,再进入 Boss 房间。 + 普通模式和困难模式使用不同的交互方式。 + + Args: + is_normal (bool): 是否为普通模式。困难模式使用问号导航。 + """ + logger.hr('前往房间入口') + logger.info(f'[大世界-移动] 前往房间入口, 是否普通={is_normal}') while 1: if self.appear(MAP_EXIT, offset=(20, 20)): break @@ -600,26 +727,34 @@ def go_month_boss_room(self, is_normal=True): self.predict_radar() grid = self.radar.select(is_port=True).first_or_none() if grid is not None and grid.location == (-3, 2): - logger.info('At room entrance') + logger.info('[大世界-移动] 在房间入口') break - logger.hr('Enter room entrance') + logger.hr('进入房间入口') while 1: if self.appear(MAP_EXIT, offset=(20, 20)): - logger.info('Entered boss room') + logger.info('[大世界-移动] 已进入Boss房间') break if is_normal: self.relative_goto(has_fleet_step=True, near_by=True, is_exclamation=True) else: if self.radar.select(is_exclamation=True).count: - logger.warning('Trying to enter month boss hard mode but is_exclamation exists') + logger.warning('[大世界-移动] 尝试进入月度Boss困难模式但存在感叹号') self.relative_goto(has_fleet_step=True, near_by=True, is_exclamation=True) else: self.relative_goto(has_fleet_step=True, near_by=True, is_question=True) def question_goto(self, has_fleet_step=False): - logger.hr('Question goto') + """导航到最近的问号标记。 + + 通过雷达查找问号位置并移动舰队前往。 + 适用于深渊海域中寻找 Boss 入口等场景。 + + Args: + has_fleet_step (bool): 是否限制舰队移动步数。 + """ + logger.hr('前往问号') while 1: # 游戏 bug:上一个已清理海域的 AUTO_SEARCH_REWARD 弹窗 if self.appear_then_click(AUTO_SEARCH_REWARD, offset=(50, 50), interval=3): @@ -643,7 +778,7 @@ def question_goto(self, has_fleet_step=False): grid = self.convert_radar_to_local(grid) self.device.click(grid) else: - logger.info('No question mark to goto, stop') + logger.info('[大世界-移动] 无问号可前往,停止') break # 等待到达 @@ -651,6 +786,15 @@ def question_goto(self, has_fleet_step=False): self.wait_until_walk_stable(confirm_timer=Timer(1.5, count=4), walk_out_of_step=False) def month_boss_goto_additional(self, location=(0, 0), has_fleet_step=False, drop=None): + """导航到月度 Boss 区域入口。 + + 通过问号的相对位置定位 Boss 区域入口并前往。 + + Args: + location (tuple[int, int]): 位置偏移量。 + has_fleet_step (bool): 是否限制舰队移动步数。 + drop: 掉落记录对象。 + """ self.update_os() self.predict() self.predict_radar() @@ -666,15 +810,23 @@ def month_boss_goto_additional(self, location=(0, 0), has_fleet_step=False, drop if has_fleet_step: grid = limit_walk(grid) if grid == (0, 0): - logger.info(f'Arrive destination: boss {location}') + logger.info(f'[大世界-移动] 到达目的地: Boss {location}') grid = self.convert_radar_to_local(grid) self.device.click(grid) else: - logger.info('No boss to goto, stop') + logger.info('[大世界-移动] 无Boss可前往,停止') self.wait_until_walk_stable(confirm_timer=Timer(1.5, count=4), walk_out_of_step=False, drop=drop) def boss_goto(self, location=(0, 0), has_fleet_step=False, drop=None, is_month=False): - logger.hr('BOSS goto') + """导航到 Boss 位置并准备战斗。 + + Args: + location (tuple[int, int]): 位置偏移量。 + has_fleet_step (bool): 是否限制舰队移动步数。 + drop: 掉落记录对象。 + is_month (bool): 是否为月度 Boss。 + """ + logger.hr('前往Boss') if is_month: self.month_boss_goto_additional(location=location, has_fleet_step=has_fleet_step, drop=drop) @@ -695,12 +847,12 @@ def boss_goto(self, location=(0, 0), has_fleet_step=False, drop=None, is_month=F if has_fleet_step: grid = limit_walk(grid) if grid == (0, 0): - logger.info(f'Arrive destination: boss {location}') + logger.info(f'[大世界-移动] 到达目的地: Boss {location}') break grid = self.convert_radar_to_local(grid) self.device.click(grid) else: - logger.info('No boss to goto, stop') + logger.info('[大世界-移动] 无Boss可前往,停止') break # 等待到达 @@ -708,6 +860,14 @@ def boss_goto(self, location=(0, 0), has_fleet_step=False, drop=None, is_month=F self.wait_until_walk_stable(confirm_timer=Timer(1.5, count=4), walk_out_of_step=False, drop=drop) def get_boss_leave_button(self): + """获取 Boss 区域的离开按钮位置。 + + 通过检测当前舰队或被塞壬捕获的舰队位置, + 计算离开按钮的点击区域。 + + Returns: + Button: 离开按钮,如果当前舰队可见则返回 None。 + """ for grid in self.view: if grid.predict_current_fleet(): return None @@ -716,13 +876,13 @@ def get_boss_leave_button(self): if len(grids) == 1: center = grids[0] elif len(grids) > 1: - logger.warning(f'Found multiple fleets in boss ({grids}), use the center one') + logger.warning(f'[大世界-舰队] 在Boss处发现多个舰队 ({grids}),使用中心的一个') center = SelectedGrids(grids).sort_by_camera_distance(self.view.center_loca)[0] else: - logger.warning('No fleet in boss, use camera center instead') + logger.warning('[大世界-舰队] Boss处无舰队,使用摄像机中心代替') center = self.view[self.view.center_loca] - logger.info(f'Fleet in boss: {center}') + logger.info(f'[大世界-舰队] Boss处的舰队: {center}') # 中心格子左侧半个格子。 area = corner2inner(center.grid2screen(area2corner((1, 0.25, 1.5, 0.75)))) button = Button(area=area, color=(), button=area, name='BOSS_LEAVE') @@ -736,7 +896,7 @@ def boss_leave(self): in: is_in_map() 或 combat_appear() out: is_in_map(), 舰队不在 Boss 区域中。 """ - logger.hr('BOSS leave') + logger.hr('离开Boss') # 更新本地视野 self.update_os() self.predict() @@ -748,7 +908,7 @@ def boss_leave(self): if self.is_in_map(): self.predict_radar() if self.radar.select(is_enemy=True): - logger.info('Fleet left boss, boss found') + logger.info('[大世界-舰队] 舰队离开Boss,找到Boss') break # 意外重新进入 Boss @@ -786,7 +946,7 @@ def boss_leave(self): click_timer.reset() continue else: - logger.info('Fleet left boss, current fleet found') + logger.info('[大世界-舰队] 舰队离开Boss,找到当前舰队') break def boss_clear(self, has_fleet_step=True, is_month=False, allow_submarine_call=True): @@ -805,7 +965,7 @@ def boss_clear(self, has_fleet_step=True, is_month=False, allow_submarine_call=T in: 塞壬日志(深渊),Boss 已出现。 out: 成功时为危险或安全海域;失败时仍在深渊中。 """ - logger.hr(f'BOSS clear', level=1) + logger.hr(f'清除Boss', level=1) fleets = self.parse_fleet_filter() with self.stat.new( @@ -813,12 +973,12 @@ def boss_clear(self, has_fleet_step=True, is_month=False, allow_submarine_call=T method=self.config.DropRecord_OpsiRecord ) as drop: for fleet in fleets: - logger.hr(f'Turn: {fleet}', level=2) + logger.hr(f'回合: {fleet}', level=2) if not isinstance(fleet, BossFleet): if allow_submarine_call: self.os_order_execute(recon_scan=False, submarine_call=True) else: - logger.info(f'Skip fleet-filter order `{fleet}` in abyssal') + logger.info(f'[大世界-舰队] 在深渊中跳过舰队筛选顺序 `{fleet}`') continue # 切换舰队 @@ -832,13 +992,13 @@ def boss_clear(self, has_fleet_step=True, is_month=False, allow_submarine_call=T self.fleet_set(other.fleet_index) self.fleet_set(fleet.fleet_index) else: - logger.warning(f'No other fleets from {fleets}, skip refocus') + logger.warning(f'[大世界-舰队] 从 {fleets} 无其他舰队,跳过重新聚焦') pass # 检查舰队 self.handle_os_map_fleet_lock(enable=False) if self.fleet_low_resolve_appear(): - logger.warning('Skip using current fleet because of the low resolve debuff') + logger.warning('[大世界-舰队] 因低决心debuff跳过使用当前舰队') self.boss_goto(location=fleet.standby_loca, has_fleet_step=has_fleet_step, drop=drop, is_month=is_month) continue @@ -859,7 +1019,7 @@ def boss_clear(self, has_fleet_step=True, is_month=False, allow_submarine_call=T # 结束条件 self.predict_radar() if self.radar.select(is_question=True): - logger.info('BOSS clear') + logger.info('[大世界-战斗] Boss已清除') if drop.count: drop.add(self.device.image) self.map_exit() @@ -901,10 +1061,10 @@ def is_at_front(grid): self.question_goto(has_fleet_step=True) if self.radar.select(is_enemy=True).filter(is_at_front): - logger.info('Found boss at front') + logger.info('[大世界-搜索] 在前方找到Boss') break else: - logger.info('No boss at front, retry question_goto') + logger.info('[大世界-搜索] 前方无Boss,重试问号前往') continue result = self.boss_clear(has_fleet_step=True, allow_submarine_call=False) @@ -923,10 +1083,10 @@ def get_stronghold_percentage(self): for starter in ['100', '80', '60', '40', '20', '0']: if result.startswith(starter): result = starter - logger.attr('STRONGHOLD_PERCENTAGE', result) + logger.attr('要塞百分比', result) return result - logger.warning(f'Unexpected STRONGHOLD_PERCENTAGE: {result}') + logger.warning(f'[大世界-要塞] 异常的要塞百分比: {result}') return result def get_second_fleet(self): @@ -941,7 +1101,7 @@ def get_second_fleet(self): second = 2 else: second = 1 - logger.attr('Second_fleet', second) + logger.attr('第二舰队', second) return second @staticmethod @@ -984,7 +1144,7 @@ def click_nearest_object(self): try: nearest = self.convert_radar_to_local(nearest) except KeyError: - logger.info('Radar grid not on local map') + logger.info('[大世界-雷达] 雷达格子不在本地地图上') self._nearest_object_click_timer.reset() return False self.device.click(nearest) diff --git a/module/os/globe_camera.py b/module/os/globe_camera.py index 660725793..e2182f34b 100644 --- a/module/os/globe_camera.py +++ b/module/os/globe_camera.py @@ -1,3 +1,23 @@ +""" +大世界全球地图摄像机控制模块。 + +负责全球地图(Globe Map)上的摄像机操作,包括视角平移、海域定位、 +坐标转换以及塞壬要塞搜索等功能。 + +主要类: + GlobeCamera: 全球地图摄像机控制类,整合全球地图操作和海域管理。 + +坐标系说明: + globe 坐标系: 全球地图的二维坐标系,原点为地图左上角。 + screen 坐标系: 屏幕像素坐标系。 + zone.location: 海域在全球地图坐标系中的位置。 + +术语: + 全球地图 (Globe Map): 大世界的整体地图视图,包含所有海域。 + 海域 (Zone): 全球地图上的一个可进入区域。 + 塞壬要塞 (Siren Stronghold): 全球地图上的特殊海域类型, + 以红色漩涡标记,完成可获得特殊奖励。 +""" from module.base.timer import Timer from module.base.utils import * from module.exception import GameStuckError @@ -11,19 +31,40 @@ class GlobeCamera(GlobeOperation, ZoneManager): + """全球地图摄像机控制类。 + + 提供全球地图上的摄像机操控能力,包括视角平移 (swipe)、 + 海域聚焦 (focus)、坐标系转换以及塞壬要塞搜索。 + + 通过组合 GlobeOperation(全球地图操作)和 ZoneManager(海域管理), + 实现从全球地图层面的完整海域导航。 + + Attributes: + globe (GlobeDetection): 全球地图检测器实例。 + globe_camera (tuple[float, float]): 当前摄像机在全球地图坐标系中的位置。 + """ globe: GlobeDetection globe_camera: tuple def _globe_init(self): - """ - Call this method before doing anything. + """初始化全球地图检测器。 + + 在进行任何全球地图操作前必须调用此方法。 + 仅在首次调用时加载全球地图资源。 """ if not hasattr(self, 'globe'): self.globe = GlobeDetection(self.config) self.globe.load_globe_map() def globe_update(self): - # Handle random black screenshots + """更新全球地图状态。 + + 确保当前处于全球地图视图,然后加载全球地图数据并更新 + 摄像机位置。自动处理各种弹窗和意外页面跳转。 + + Raises: + GameStuckError: 5 秒内无法进入全球地图视图时抛出。 + """ timeout = Timer(5, count=10).start() while 1: if timeout.reached(): @@ -72,14 +113,14 @@ def globe_update(self): timeout.reset() continue - logger.warning('Trying to do globe_update(), but not in os globe map') + logger.warning('[大世界-地球仪] 尝试执行globe_update(),但不在大世界地球仪地图中') continue self._globe_init() self.globe.load(self.device.image) self.globe_camera = self.globe.center_loca center = self.camera_to_zone(self.globe.center_loca) - logger.attr('Globe_center', center.zone_id) + logger.attr('地球仪中心', center.zone_id) def globe_swipe(self, vector, box=(20, 220, 980, 620)): """ @@ -92,7 +133,7 @@ def globe_swipe(self, vector, box=(20, 220, 980, 620)): """ name = 'GLOBE_SWIPE_' + '_'.join([str(int(round(x))) for x in vector]) if np.linalg.norm(vector) <= 25: - logger.warning(f'Globe swipe to short: {vector}') + logger.warning(f'地球仪滑动过短: {vector}') vector = np.sign(vector) * 25 if self.config.DEVICE_CONTROL_METHOD == 'minitouch': @@ -110,6 +151,11 @@ def globe_swipe(self, vector, box=(20, 220, 980, 620)): self.globe_update() def globe_wait_until_stable(self): + """等待全球地图摄像机稳定。 + + 持续更新全球地图状态,直到摄像机位置不再发生变化。 + 期间会处理海域固定弹窗。 + """ prev = self.globe_camera interval = Timer(1) confirm = Timer(0.5, count=1).start() @@ -123,7 +169,7 @@ def globe_wait_until_stable(self): # End if np.linalg.norm(np.subtract(self.globe_camera, prev)) < 10: if confirm.reached(): - logger.info('Globe map stabled') + logger.info('[大世界-地球仪] 地球仪地图已稳定') break else: confirm.reset() @@ -134,10 +180,26 @@ def globe_wait_until_stable(self): prev = self.globe_camera def globe2screen(self, points): + """将全球地图坐标转换为屏幕坐标。 + + Args: + points (np.ndarray): 全球地图坐标点数组。 + + Returns: + np.ndarray: 对应的屏幕坐标点数组。 + """ points = np.array(points) - self.globe_camera + self.globe.homo_center return self.globe.globe2screen(points).round() def screen2globe(self, points): + """将屏幕坐标转换为全球地图坐标。 + + Args: + points (np.ndarray): 屏幕坐标点数组。 + + Returns: + np.ndarray: 对应的全球地图坐标点数组。 + """ points = self.globe.screen2globe(points).round() return points - self.globe.homo_center + self.globe_camera @@ -156,11 +218,14 @@ def zone_to_button(self, zone): return button def globe_in_sight(self, zone, swipe_limit=(620, 340), sight=(20, 220, 980, 620)): - """ + """将目标海域平移到屏幕视野范围内。 + + 如果目标海域不在指定视野区域内,通过反复平移摄像机直到其可见。 + Args: - zone (str, int, Zone): Name in CN/EN/JP/TW, zone id, or Zone instance. - swipe_limit (tuple): - sight (tuple): + zone (str, int, Zone): 海域名称(CN/EN/JP/TW)、海域 ID 或 Zone 实例。 + swipe_limit (tuple[int, int]): 单次平移的最大像素距离限制。 + sight (tuple[int, int, int, int]): 屏幕上的有效视野区域 (x1, y1, x2, y2)。 """ zone = self.name_to_zone(zone) # logger.info(f'Globe in_sight: {zone}') @@ -204,10 +269,10 @@ def globe_wait_until_zone_pinned(self, zone, skip_first_screenshot=True): if self.is_zone_pinned(): if self.get_globe_pinned_zone() == zone: - logger.attr('Globe_pinned', zone) + logger.attr('固定海域', zone) return True if timeout.reached(): - logger.warning('Wait until zone pinned timeout') + logger.warning('[大世界-地球仪] 等待区域固定超时') return False def globe_focus_to(self, zone): @@ -223,7 +288,7 @@ def globe_focus_to(self, zone): out: IN_GLOBE, zone selected, ZONE_ENTRANCE """ zone = self.name_to_zone(zone) - logger.info(f'Globe focus_to: {zone.zone_id}') + logger.info(f'[大世界-地球仪] 聚焦到: {zone.zone_id}') while 1: if self.handle_zone_pinned(): @@ -286,26 +351,26 @@ def _find_siren_stronghold(self, zones): while zones: prev = self.camera_to_zone(self.globe_camera) zone = zones.sort_by_camera_distance(prev.location)[0] - logger.info(f'Find siren stronghold around {zone}') + logger.info(f'[大世界-地球仪] 查找塞壬要塞 around {zone}') self.globe_in_sight(zone, sight=sight) to_check = zones.filter(lambda z: point_in_area(self.globe2screen([z.location])[0], area=sight)) for zone in to_check: if self._globe_predict_stronghold(zone): - logger.info(f'Zone {zone.zone_id} is a siren stronghold') + logger.info(f'[大世界-地球仪] 区域 {zone.zone_id} 是塞壬要塞') self.globe_focus_to(zone) if self.get_zone_pinned_name() == 'STRONGHOLD': - logger.info('Confirm it is a siren stronghold') + logger.info('[大世界-地球仪] 确认为塞壬要塞') return zone else: - logger.warning('Not a siren stronghold, continue searching') + logger.warning('[大世界-地球仪] 不是塞壬要塞,继续搜索') self.ensure_no_zone_pinned() else: - logger.info(f'Zone {zone.zone_id} is not a siren stronghold') + logger.info(f'[大世界-地球仪] 区域 {zone.zone_id} 不是塞壬要塞') zones = zones.delete(to_check) - logger.info('Find siren stronghold finished') + logger.info('[大世界-地球仪] 查找塞壬要塞完成') return None def find_siren_stronghold(self): @@ -317,7 +382,7 @@ def find_siren_stronghold(self): in: in_globe out: in_globe, is_zone_pinned() if found. """ - logger.hr(f'Find siren stronghold', level=1) + logger.hr(f'[大世界-地球仪] 查找塞壬要塞', level=1) region = self.camera_to_zone(self.globe_camera).region order = [1, 2, 4, 3] if region not in order: @@ -331,11 +396,11 @@ def find_siren_stronghold(self): order = order * 2 order = order[index:index + 4] for region in order: - logger.hr(f'Find siren stronghold in region {region}', level=2) + logger.hr(f'[大世界-地球仪] 查找塞壬要塞 in region {region}', level=2) zones = self.zones.select(region=region, is_port=False) result = self._find_siren_stronghold(zones) if result is not None: return result - logger.info('No more siren stronghold') + logger.info('[大世界-地球仪] 没有更多塞壬要塞') return None diff --git a/module/os/globe_detection.py b/module/os/globe_detection.py index bcca87eaf..cc23f5951 100644 --- a/module/os/globe_detection.py +++ b/module/os/globe_detection.py @@ -1,3 +1,24 @@ +""" +大世界全球地图检测模块。 + +负责全球地图 (Globe Map) 的图像检测和定位,通过单应性变换和 +模板匹配确定当前摄像机在全球地图中的位置。 + +主要类: + GlobeDetection: 全球地图检测器,通过模板匹配定位当前摄像机位置。 + +检测流程: + 1. 加载预处理好的全球地图边界图像 (GLOBE_MAP)。 + 2. 对游戏截图进行透视变换和边界提取。 + 3. 将提取的边界与全球地图进行模板匹配 (cv2.matchTemplate)。 + 4. 根据匹配结果计算摄像机在全球地图坐标系中的位置。 + +坐标系: + globe 坐标系: 全球地图的二维坐标系,原点为地图左上角, + 范围约为 (0, 0) 到 GLOBE_MAP_SHAPE (2570, 1696)。 + screen 坐标系: 1280x720 的屏幕像素坐标系。 + homo_center: 单应性变换后的屏幕中心在全球地图坐标系中的位置。 +""" import time from module.base.utils import * @@ -12,8 +33,10 @@ class GlobeDetection: - """ - Detect globe map in Operation Siren. + """全球地图检测器。 + + 通过单应性变换和模板匹配,在大世界模式下确定当前摄像机 + 在全球地图中的位置。支持屏幕坐标与全球地图坐标的双向转换。 Examples: globe = GlobeDetection(AzurLaneConfig('template')) @@ -22,15 +45,24 @@ class GlobeDetection: Logs: globe_center: (1305, 325) 0.062s similarity: 0.354 + + Attributes: + globe (np.ndarray): 预处理后的全球地图边界图像。 + homo_center (tuple[int, int]): 屏幕中心在全球地图坐标系中的位置。 + center_loca (tuple[float, float]): 当前摄像机在全球地图坐标系中的位置。 + config (AzurLaneConfig): 配置对象。 + perspective (Perspective): 透视检测器。 + homography (Homography): 单应性变换器。 """ globe = None homo_center: tuple center_loca: tuple def __init__(self, config): - """ + """初始化全球地图检测器。 + Args: - config (AzurLaneConfig): + config (AzurLaneConfig): 配置对象,包含全球地图检测相关参数。 """ self.config = config self.perspective = Perspective(config) @@ -38,13 +70,18 @@ def __init__(self, config): self._globe_map_loaded = False def load_globe_map(self): - """ - Call this method before doing anything. + """加载全球地图资源和单应性变换矩阵。 + + 必须在进行任何全球地图操作前调用。仅首次调用时实际加载, + 后续调用直接返回 False。 + + Returns: + bool: 首次加载成功返回 True,已加载返回 False。 """ if self._globe_map_loaded: return False - logger.info('Loading OS globe map') + logger.info('[大世界-检测] 加载全球地图') # Load GLOBE_MAP image = load_image(GLOBE_MAP) @@ -66,9 +103,25 @@ def load_globe_map(self): return True def screen2globe(self, points): + """将屏幕坐标转换为全球地图坐标。 + + Args: + points (np.ndarray): 屏幕坐标点数组。 + + Returns: + np.ndarray: 对应的全球地图坐标点数组。 + """ return perspective_transform(points, data=self.homography.homo_data) def globe2screen(self, points): + """将全球地图坐标转换为屏幕坐标。 + + Args: + points (np.ndarray): 全球地图坐标点数组。 + + Returns: + np.ndarray: 对应的屏幕坐标点数组。 + """ return perspective_transform(points, data=self.homography.homo_invt) def find_peaks(self, image, para): @@ -110,9 +163,17 @@ def perspective_transform(self, image): return image def load(self, image): - """ + """从截图中检测当前摄像机在全球地图中的位置。 + + 对截图进行透视变换和边界提取后,与预加载的全球地图进行模板匹配, + 计算当前摄像机位置并存储到 self.center_loca。 + Args: - image (np.ndarray): + image (np.ndarray): 当前游戏截图(1280x720)。 + + Logs: + globe_center: 摄像机在全球地图坐标系中的位置。 + similarity: 模板匹配相似度(低于 0.1 时发出警告)。 """ self.load_globe_map() start_time = time.time() @@ -128,7 +189,7 @@ def load(self, image): self.center_loca = loca time_cost = round(time.time() - start_time, 3) - logger.attr_align('globe_center', loca) - logger.attr_align('similarity', float2str(similarity), front=float2str(time_cost) + 's') + logger.attr_align('全球地图中心', loca) + logger.attr_align('相似度', float2str(similarity), front=float2str(time_cost) + 's') if similarity < 0.1: - logger.warning('Low similarity when matching OS globe') + logger.warning('[大世界-检测] 匹配全球地图时相似度过低') diff --git a/module/os/globe_operation.py b/module/os/globe_operation.py index 76b8bf6fc..447e67f59 100644 --- a/module/os/globe_operation.py +++ b/module/os/globe_operation.py @@ -1,3 +1,23 @@ +"""全球地图操作模块。 + +管理大世界全球地图视图中的操作,包括: +- 海域类型检测(危险区、安全区、隐秘海域、深渊海域、要塞、档案) +- 海域选择和进入 +- 固定海域(Pinned Zone)的导航 +- 行动力管理 +- 自动搜索奖励处理 + +海域类型: +- DANGEROUS: 危险海域(未清理) +- SAFE: 安全海域(已清理) +- OBSCURE: 隐秘海域(特殊条件进入) +- ABYSSAL: 深渊海域(强力敌人) +- STRONGHOLD: 塞壬要塞 +- ARCHIVE: 档案海域 + +继承自 ActionPointHandler,提供行动力管理能力。 +""" + import time import module.base.utils as _base_utils from module.base.timer import Timer @@ -9,8 +29,11 @@ from module.os_handler.port import PORT_CHECK from module.ui.assets import BACK_ARROW +# 海域类型按钮列表 ZONE_TYPES = [ZONE_DANGEROUS, ZONE_SAFE, ZONE_OBSCURE, ZONE_ABYSSAL, ZONE_STRONGHOLD, ZONE_ARCHIVE] +# 海域选择按钮列表(与 ZONE_TYPES 一一对应) ZONE_SELECT = [SELECT_DANGEROUS, SELECT_SAFE, SELECT_OBSCURE, SELECT_ABYSSAL, SELECT_STRONGHOLD, SELECT_ARCHIVE] +# 固定海域检测的资源列表 ASSETS_PINNED_ZONE = ZONE_TYPES + [ZONE_ENTRANCE, ZONE_SWITCH, ZONE_PINNED] # Under a certain scene, the similarity are as follows: @@ -207,7 +230,7 @@ def ensure_zone_select_expanded(self): record = len(selection) self.device.screenshot() - logger.warning('Failed to ensure zone selection expanded, assume expanded') + logger.warning('[大世界-操作] 无法确保区域选择已展开,假设已展开') return self.get_zone_select() def zone_select_enter(self): @@ -232,7 +255,7 @@ def zone_select_execute(self, button): in: is_in_zone_select out: is_zone_pinned """ - logger.info(f'Zone select: {button}') + logger.info(f'区域选择: {button}') for _ in self.loop(): # End if self.is_zone_pinned(): @@ -257,7 +280,7 @@ def zone_type_select(self, types=('SAFE', 'DANGEROUS')): out: is_zone_pinned """ if not self.zone_has_switch(): - logger.info('Zone has no type to select, skip') + logger.info('区域无类型可选,跳过') return True if isinstance(types, str): @@ -275,36 +298,36 @@ def get_button(selection_): pinned = self.get_zone_pinned_name() if pinned in types: - logger.info(f'Already selected at {pinned}') + logger.info(f'已选择于 {pinned}') return True for _ in range(3): self.zone_select_enter() selection = self.ensure_zone_select_expanded() - logger.attr('Zone_selection', selection) + logger.attr('海域选择', selection) button = get_button(selection) if button is None: # 获取所有可用的区域类型(不含SELECT_前缀) available_types = [getattr(sel, 'name', str(sel)).replace('SELECT_', '') for sel in selection] logger.warning( - f'Zone type {requested_type} not found in selection, ' - f'available types: {available_types}, ' - f'fallback to default (SAFE > DANGEROUS)' + f'[大世界-操作] 海域类型 {requested_type} 未在选择列表中, ' + f'可用类型: {available_types}, ' + f'回退到默认 (SAFE > DANGEROUS)' ) # 回退到安全的默认优先级,而不是选择列表中的第一个 # 这样在有深渊海域时不会错误地进入深渊而是选择安全海域 types = ('SAFE', 'DANGEROUS') button = get_button(selection) if button is None: - logger.warning('No zone type selection available') + logger.warning('[大世界-操作] 无区域类型可选') return False self.zone_select_execute(button) if self.pinned_to_name(button) == self.get_zone_pinned_name(): return True - logger.warning('Failed to select zone type after 3 trial') + logger.warning('[大世界-操作] 尝试3次后仍无法选择区域类型') return False def zone_has_safe(self): @@ -350,7 +373,7 @@ def os_globe_goto_map(self, skip_first_screenshot=True): break if self.appear(PORT_CHECK, offset=(20, 20), interval=3): - logger.info('Accidentally entered port, exiting') + logger.info('误入港口,退出') self.device.click(BACK_ARROW) self.interval_reset(GLOBE_GOTO_MAP) continue @@ -383,8 +406,7 @@ def os_map_goto_globe(self, unpin=True): click_count += 1 if click_count >= 5: # 当海域存在探索奖励时,游戏不会允许你离开。 - logger.warning('Unable to goto globe, ' - 'there might be uncollected zone exploration rewards preventing exit') + logger.warning('[大世界-操作] 无法前往全球地图, 可能有未收集的海域探索奖励阻止退出') raise RewardUncollectedError continue if self.appear_then_click(MAP_GOTO_GLOBE_FOG, interval=5): @@ -395,7 +417,7 @@ def os_map_goto_globe(self, unpin=True): continue # 意外进入港口 if self.appear(PORT_CHECK, offset=(20, 20), interval=5): - logger.info(f'Page switch: {PORT_CHECK} -> {BACK_ARROW}') + logger.info(f'页面切换: {PORT_CHECK} -> {BACK_ARROW}') self.device.click(BACK_ARROW) continue # 弹窗:AUTO_SEARCH_REWARD 出现较慢 @@ -450,10 +472,10 @@ def globe_enter(self, zone): if self.is_zone_pinned(): if self.appear(ZONE_LOCKED, offset=(20, 20)): - logger.warning(f'Zone {zone} locked, neighbouring zones may not have been explored') + logger.warning(f'[大世界-操作] 海域 {zone} 已锁定, 相邻海域可能未被探索过') raise OSExploreError if click_count > 5: - logger.warning(f'Unable to enter zone {zone}, neighbouring zones may not have been explored') + logger.warning(f'[大世界-操作] 无法进入海域 {zone}, 相邻海域可能未被探索过') raise OSExploreError if click_timer.reached(): # 点太快会进不去 浪费时间 diff --git a/module/os/globe_zone.py b/module/os/globe_zone.py index 4533d0821..36d9741f3 100644 --- a/module/os/globe_zone.py +++ b/module/os/globe_zone.py @@ -1,3 +1,21 @@ +"""全球地图海域数据管理模块。 + +定义海域(Zone)数据结构和海域管理器(ZoneManager)。 + +海域是大世界全球地图上的可进入区域,每个海域有: +- zone_id: 海域编号 +- shape: 地图形状(如 J10、P16) +- hazard_level: 侵蚀等级(1-7) +- cn/en/jp/tw: 各服务器的名称 +- area_pos: 信息栏固定位置 +- offset_pos: 任务固定位置偏移 +- region: 所在区域(1-5,对应全球地图的象限) +- is_port: 是否为港口 +- is_azur_port: 是否为碧蓝航线港口 + +ZoneManager 管理所有海域实例,提供按类型、区域、状态的查询接口。 +""" + import numpy as np from module.base.decorator import cached_property @@ -8,6 +26,21 @@ class Zone: + """海域数据类。 + + 存储单个海域的所有属性信息。 + + Attributes: + zone_id (int): 海域编号。 + shape (str): 地图形状,如 'J10'。 + hazard_level (int): 侵蚀等级,1-7。 + cn/en/jp/tw (str): 各服务器的海域名称。 + area_pos (tuple): 信息栏固定位置。 + offset_pos (tuple): 任务固定位置偏移。 + region (int): 所在区域(1=左上, 2=右上, 3=左下, 4=右下, 5=中心)。 + is_port (bool): 是否为港口。 + is_azur_port (bool): 是否为碧蓝航线港口。 + """ zone_id: int # Map shape, such as J10 shape: str diff --git a/module/os/map.py b/module/os/map.py index 1641cec9a..e39b1a63d 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -1,5 +1,30 @@ -# 此文件处理大世界(Operation Siren)模式下的地图导航与海域管理。 -# 包括全球地图切换、海域初始化、处理各种地图减益状态以及海域自动搜索的守护逻辑。 +""" +大世界地图导航与海域管理模块。 + +负责大世界(Operation Siren)模式下的地图导航与海域管理,包括: +- 全球地图与海域视图的切换。 +- 海域初始化和当前海域检测。 +- 舰队修理、士气恢复和 EMP 减益处理。 +- 自律寻敌守护进程和自动搜索管理。 +- 战略搜索和地图重扫。 +- 行动力管理和月度重置处理。 + +主要类: + OSMap: 大世界地图主控类,整合舰队、摄像机、仓库和战略搜索功能。 + +术语: + 大世界 (Operation Siren / OS): 碧蓝航线的高难度 PVE 模式。 + 全球地图 (Globe Map): 大世界的整体地图视图,包含所有海域。 + 海域 (Zone): 全球地图上的一个可进入区域。 + 行动力 (Action Point / AP): 进入海域需要消耗的资源。 + 自律寻敌 (Auto Search): 自动清理海域中敌人的功能。 + 战略搜索 (Strategic Search): 使用战略装置进行的特殊搜索。 + 塞壬研究装置 (Siren Scanning Device): 大世界地图上的特殊装置。 + 余烬 (Ash/Ember): 大世界中的特殊系统。 + 塞壬要塞 (Siren Stronghold): 特殊海域类型。 + 侵蚀1练级 (Hazard 1 Leveling): 在低难度海域反复刷经验的策略。 + 智能调度+ (Smart Scheduling+): 跨任务的自动化调度功能。 +""" import time from contextlib import suppress from sys import maxsize @@ -46,6 +71,25 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): + """大世界地图主控类。 + + 整合舰队控制 (OSFleet)、地图操作 (Map)、全球地图摄像机 (GlobeCamera)、 + 仓库管理 (StorageHandler) 和战略搜索 (StrategicSearchHandler), + 提供大世界模式下的完整自动化能力。 + + 核心职责: + - 大世界初始化 (os_init): 确保进入正确的海域并完成初始清理。 + - 海域导航 (globe_goto): 通过全球地图切换到目标海域。 + - 舰队维护: 修理 (fleet_repair)、士气恢复 (fleet_resolve)、EMP 解除。 + - 自律寻敌 (run_auto_search): 清理当前海域的所有敌人和事件。 + - 地图重扫 (map_rescan): 自动搜索后清理遗漏的事件和装置。 + - 行动力管理 (get_action_point_limit): 月末自动调整行动力策略。 + + Attributes: + _auto_search_battle_count (int): 当前自动搜索的战斗次数。 + _solved_map_event (set[str]): 已处理的地图事件类型集合。 + _solved_fleet_mechanism (bool): 是否已解锁双舰队机关。 + """ def is_smart_scheduling_enabled(self) -> bool: """ 统一判断是否启用了智能调度+(侵蚀1与补黄币任务共享的开关逻辑)。 @@ -85,7 +129,7 @@ def os_init(self): in: IN_MAP 或 IN_GLOBE 或 page_os 或任意页面 out: IN_MAP """ - logger.hr("OS init", level=1) + logger.hr("大世界初始化", level=1) kwargs = {} if "iM" in self.config.task.command: for key in self.config.bound.keys(): @@ -107,7 +151,7 @@ def os_init(self): # 界面切换 if self.is_in_map(): - logger.info("Already in os map") + logger.info("[大世界-地图] 已在大世界地图中") elif self.is_in_globe(): self.os_globe_goto_map() else: @@ -126,7 +170,7 @@ def os_init(self): # 从特殊海域类型退出,仅 SAFE 和 DANGEROUS 可接受。 if self.is_in_special_zone(): logger.warning( - "OS is in a special zone type, while SAFE and DANGEROUS are acceptable" + "[大世界-地图] 大世界在特殊海域类型, 仅 SAFE 和 DANGEROUS 可接受" ) self.map_exit() @@ -158,7 +202,7 @@ def os_init(self): def run_first_auto_search(self): if self.zone.zone_id == 154: - logger.info("In zone 154, skip running first auto search") + logger.info("[大世界-地图] 在区域154,跳过首次自动搜索") self.handle_ash_beacon_attack() else: self.run_auto_search(rescan=True) @@ -198,10 +242,10 @@ def globe_goto( out: IN_MAP """ zone = self.name_to_zone(zone) - logger.hr(f"Globe goto: {zone}") + logger.hr(f"地球仪前往: {zone}") if self.zone == zone: if refresh: - logger.info("Goto another zone to refresh current zone") + logger.info("[大世界-地图] 前往其他区域刷新当前区域") self.globe_goto( self.zone_nearest_azur_port(self.zone), types=("SAFE", "DANGEROUS"), @@ -210,7 +254,7 @@ def globe_goto( else: if self.is_in_globe(): self.os_globe_goto_map() - logger.info("Already at target zone") + logger.info("[大世界-地图] 已在目标区域") return False # MAP_EXIT 处理 if self.is_in_special_zone(): @@ -223,7 +267,7 @@ def globe_goto( self.globe_update() self.globe_focus_to(zone) if stop_if_safe and self.zone_has_safe(): - logger.info("Zone is safe, stopped") + logger.info("[大世界-地图] 区域安全,停止") self.ensure_no_zone_pinned() return False self.zone_type_select(types=types) @@ -252,7 +296,7 @@ def os_map_goto_globe(self, *args, **kwargs): self.run_auto_search(rescan=True, after_auto_search=False) continue - logger.error("Failed to solve uncollected rewards") + logger.error("[大世界-地图] 解决未收集奖励失败") raise GameTooManyClickError def port_goto(self, allow_port_arrive=True): @@ -267,7 +311,7 @@ def port_goto(self, allow_port_arrive=True): super().port_goto(allow_port_arrive=allow_port_arrive) return True except MapWalkError: - logger.info("Goto another port then re-enter") + logger.info("[大世界-地图] 前往其他港口再重新进入") prev = self.zone if prev == self.name_to_zone("NY City"): other = self.name_to_zone("Liverpool") @@ -276,7 +320,7 @@ def port_goto(self, allow_port_arrive=True): self.globe_goto(other) self.globe_goto(prev) - logger.warning("Failed to solve MapWalkError when going to port") + logger.warning("[大世界-地图] 前往港口时解决地图移动错误失败") return False def fleet_repair(self, revert=True): @@ -286,10 +330,10 @@ def fleet_repair(self, revert=True): Args: revert (bool): 是否返回之前的海域。 """ - logger.hr("OS fleet repair") + logger.hr("大世界舰队维修") prev = self.zone if self.zone.is_azur_port: - logger.info("Already in azur port") + logger.info("[大世界-维修] 已在碧蓝港口") else: self.globe_goto(self.zone_nearest_azur_port(self.zone)) @@ -329,12 +373,12 @@ def handle_fleet_repair(self, revert=True): # 所以 threshold=0 仍会触发阵亡舰船的修理,这可能不是预期行为。 if trigger_threshold <= 0: logger.info( - f"Repair threshold: {repair_threshold}, Repair pack threshold: {repair_pack_threshold}, " - f"Trigger threshold: {trigger_threshold}, skip fleet repair" + f"修理阈值: {repair_threshold}, 维修包阈值: {repair_pack_threshold}, " + f"触发阈值: {trigger_threshold}, 跳过舰队维修" ) return False if self.is_in_special_zone(): - logger.info("OS is in a special zone type, skip fleet repair") + logger.info("[大世界-维修] 大世界在特殊区域类型,跳过舰队维修") return False self.hp_get() @@ -344,9 +388,9 @@ def handle_fleet_repair(self, revert=True): ] if any(check): logger.info( - "At least one ship is below threshold " + "至少有一艘舰船低于阈值 " f"{int(trigger_threshold * 100)}%, " - "start fleet repair by current config" + "开始按当前配置修理舰队" ) repaired = self.handle_fleet_repair_by_config( revert=revert, trigger_threshold=trigger_threshold @@ -354,12 +398,12 @@ def handle_fleet_repair(self, revert=True): self.hp_reset() if repaired: return True - logger.info("Fleet repair triggered but no actual repair was performed") + logger.info("[大世界-维修] 触发了舰队维修但未执行实际维修") return False logger.info( - "No ship found to be below threshold " - f"{int(trigger_threshold * 100)}%, " - "continue OS exploration" + "未发现低于阈值 " + f"{int(trigger_threshold * 100)}% 的舰船, " + "继续大世界探索" ) self.hp_reset() return False @@ -407,9 +451,9 @@ def handle_storage_one_fleet_repair(self, fleet_index, threshold): ] if any(check): logger.info( - f"At least one ship in fleet {fleet_index} is below threshold " + f"舰队 {fleet_index} 中至少有一艘舰船低于阈值 " f"{int(threshold * 100)}%, " - "use repair packs for repairs" + "使用维修包进行维修" ) had_timeout = False for index, repair in enumerate(check): @@ -418,36 +462,36 @@ def handle_storage_one_fleet_repair(self, fleet_index, threshold): ship_hp = round(self.hp[index] * 100) if index < len(self.hp) else '?' result = self.repair_pack_use(hp_grids.buttons[index]) if result == RepairResult.SUCCESS: - logger.info(f'Ship #{index + 1} in fleet {fleet_index} repaired') + logger.info(f'[大世界-维修] 舰队{fleet_index}中第{index + 1}艘舰船已维修') elif result == RepairResult.PACK_INSUFFICIENT: # 维修箱确认耗尽,后续舰船无法修理,立即停止 # 返回 False 以区别于"无需修理"的 None logger.warning( - f'Repair pack exhausted at ship #{index + 1} (HP {ship_hp}%) ' - f'in fleet {fleet_index}, stop repairing remaining ships' + f'[大世界-维修] 维修包在第 {index + 1} 艘舰船 (血量 {ship_hp}%) ' + f'(舰队 {fleet_index}) 处耗尽, 停止维修剩余舰船' ) self.hp_reset() return False elif result == RepairResult.TIMEOUT: # 超时或未知错误,记录警告但继续尝试下一艘(可能只是临时卡顿) logger.warning( - f'Repair timed out at ship #{index + 1} (HP {ship_hp}%) ' - f'in fleet {fleet_index}, skip this ship and continue' + f'[大世界-维修] 第 {index + 1} 艘舰船 (血量 {ship_hp}%) ' + f'(舰队 {fleet_index}) 维修超时, 跳过此舰船继续' ) had_timeout = True if had_timeout: logger.warning( - f'Fleet {fleet_index} partially repaired ' - f'(some ships timed out, result uncertain)' + f'舰队 {fleet_index} 部分维修完成 ' + f'(部分舰船超时, 结果不确定)' ) else: - logger.info(f'All ships in fleet {fleet_index} repaired') + logger.info(f'[大世界-维修] 舰队{fleet_index}所有舰船已维修') self.hp_reset() return True logger.info( - f"No ship in fleet {fleet_index} found to be below threshold " - f"{int(threshold * 100)}%, " - "continue OS exploration" + f"舰队 {fleet_index} 中未发现低于阈值 " + f"{int(threshold * 100)}% 的舰船, " + "继续大世界探索" ) self.hp_reset() # 返回 None 表示"无需修理",与 False(维修箱耗尽)明确区分 @@ -469,13 +513,13 @@ def handle_storage_fleet_repair( in: in_map out: in_map """ - logger.hr("OS fleet repair by repair packs") + logger.hr("大世界使用维修包维修") if fleet_index is None: fleet_index = self.fleet_selector.get() if isinstance(fleet_index, int): fleet_index = [fleet_index] if not isinstance(fleet_index, list): - logger.warning(f"Unknown fleet index: {fleet_index}") + logger.warning(f"[大世界-维修] 未知的舰队索引: {fleet_index}") return False if repair_pack_threshold is None: repair_pack_threshold = self.get_effective_repair_pack_threshold() @@ -495,7 +539,7 @@ def handle_storage_fleet_repair( elif fleet_repaired is False: # handle_storage_one_fleet_repair 返回 False 表示维修箱耗尽 # 继续尝试其他舰队只会触发超时,直接退出循环 - logger.warning('Repair pack exhausted, stop repairing remaining fleets') + logger.warning("[大世界-维修] 维修包耗尽,停止维修剩余舰队") break if any(self.need_repair): repair = True @@ -532,7 +576,7 @@ def handle_fleet_repair_by_config( """ if self.config.OpsiGeneral_UseRepairPack and self.config.SERVER not in ["cn"]: logger.warning( - f"OpsiDaily.SkipSirenResearchMission is not supported in {self.config.SERVER}" + f"[大世界-维修] 维修包功能不支持 {self.config.SERVER} 服务器" ) self.config.OpsiGeneral_UseRepairPack = False @@ -592,7 +636,7 @@ def handle_fleet_repair_by_config( elif submarine_fleet in fleet_index: fleet_index.remove(submarine_fleet) fleet_index.append(submarine_fleet) - logger.attr("Repair Fleet", fleet_index) + logger.attr("维修舰队", fleet_index) return self.handle_storage_fleet_repair( fleet_index=fleet_index, revert=revert, @@ -607,7 +651,7 @@ def fleet_resolve(self, revert=True): Args: revert (bool): 是否返回之前的海域。 """ - logger.hr("OS fleet cure low resolve debuff") + logger.hr("大世界舰队治疗低决心debuff") prev = self.zone self.globe_goto(22) @@ -629,7 +673,7 @@ def handle_fleet_resolve(self, revert=False): bool: 是否处理了低士气减益。 """ if self.is_in_special_zone(): - logger.info("OS is in a special zone type, skip fleet resolve") + logger.info("[大世界-决心] 大世界在特殊区域类型,跳过舰队决心") return False for index in [1, 2, 3, 4]: @@ -638,12 +682,12 @@ def handle_fleet_resolve(self, revert=False): if self.fleet_low_resolve_appear(): logger.info( - "At least one fleet is afflicted with the low resolve debuff" + "[大世界-决心] 至少有一支舰队受到低决心减益影响" ) self.fleet_resolve(revert) return True - logger.info("None of the fleets are afflicted with the low resolve debuff") + logger.info("[大世界-决心] 没有舰队受到低决心debuff影响") return False def handle_current_fleet_resolve(self, revert=False): @@ -657,11 +701,11 @@ def handle_current_fleet_resolve(self, revert=False): bool: 是否处理了低士气减益。 """ if self.fleet_low_resolve_appear(): - logger.info("Current fleet is afflicted with the low resolve debuff") + logger.info("[大世界-决心] 当前舰队受到低决心debuff影响") self.fleet_resolve(revert) return True - logger.info("Current fleet is not afflicted with the low resolve debuff") + logger.info("[大世界-决心] 当前舰队未受到低决心debuff影响") return False def handle_fleet_emp_debuff(self): @@ -673,7 +717,7 @@ def handle_fleet_emp_debuff(self): bool: 是否已解决。 """ if self.is_in_special_zone(): - logger.info("OS is in a special zone type, skip handle_fleet_emp_debuff") + logger.info("[大世界-EMP] 大世界在特殊区域类型,跳过处理舰队EMP debuff") return False def has_emp_debuff(): @@ -681,28 +725,28 @@ def has_emp_debuff(): for trial in range(5): if not has_emp_debuff(): - logger.info("No EMP debuff on current fleet") + logger.info("[大世界-EMP] 当前舰队无EMP debuff") return trial > 0 current = self.get_fleet_current_index() - logger.hr(f"Solve EMP debuff on fleet {current}") + logger.hr(f"解决舰队 {current} 的EMP debuff") self.globe_goto(self.zone_nearest_azur_port(self.zone)) - logger.info("Find a fleet without EMP debuff") + logger.info("[大世界-EMP] 找到无EMP debuff的舰队") for fleet in [1, 2, 3, 4]: self.fleet_set(fleet) if has_emp_debuff(): - logger.info(f"Fleet {fleet} is under EMP debuff") + logger.info(f"[大世界-EMP] 舰队 {fleet} 受到EMP debuff影响") continue else: - logger.info(f"Fleet {fleet} is not under EMP debuff") + logger.info(f"[大世界-EMP] 舰队 {fleet} 未受到EMP debuff影响") break - logger.info("Solve EMP debuff by going somewhere else") + logger.info("[大世界-EMP] 通过前往其他地方解决EMP debuff") self.port_goto(allow_port_arrive=False) self.fleet_set(current) - logger.warning("Failed to solve EMP debuff after 5 trial, assume solved") + logger.warning("[大世界-EMP] 尝试5次后仍无法解决EMP debuff,假设已解决") return True def handle_fog_block(self, repair=True): @@ -717,8 +761,7 @@ def handle_fog_block(self, repair=True): return False logger.warning( - f"Triggered stuck fog status, restarting " - f"game to resolve and continue " + f"[大世界-地图] 触发卡死迷雾状态, 重启游戏以恢复并继续 " f"{self.config.task.command}" ) @@ -746,49 +789,49 @@ def get_action_point_limit(self, preserve=False): """ if preserve: if self.config.is_task_enabled("OpsiCrossMonth"): - logger.info("Preserve action points until OpsiCrossMonth") + logger.info("[大世界-调度] 保留行动点直到大世界跨月") return maxsize else: logger.info( - "OpsiCrossMonth is not enabled, skip OpsiMeowfficerFarming.APPreserveUntilReset" + "[大世界-调度] OpsiCrossMonth 未启用, 跳过 OpsiMeowfficerFarming.APPreserveUntilReset" ) remain = get_os_reset_remain() if remain <= 0: if self.config.is_task_enabled("OpsiCrossMonth"): logger.info( - "Just less than 1 day to OpSi reset, OpsiCrossMonth is enabled, " - "set OpsiMeowfficerFarming.ActionPointPreserve to 500 temporarily" + "[大世界-调度] 距大世界重置不足1天, OpsiCrossMonth 已启用, " + "临时将行动力保留设为 500" ) return 500 else: logger.info( - "Just less than 1 day to OpSi reset, " - "set ActionPointPreserve to 0 temporarily" + "[大世界-调度] 距大世界重置不足1天, " + "临时将行动力保留设为 0" ) return 0 elif self.is_cl1_mode_enabled and remain <= 2: logger.info( - "Just less than 3 days to OpSi reset, " - "set ActionPointPreserve to 2000 temporarily for hazard 1 leveling" + "[大世界-调度] 距大世界重置不足3天, " + "临时将行动力保留设为 2000 (侵蚀1练级)" ) return 2000 elif remain <= 2: logger.info( - "Just less than 3 days to OpSi reset, " - "set ActionPointPreserve to 500 temporarily" + "[大世界-调度] 距大世界重置不足3天, " + "临时将行动力保留设为 500" ) return 500 else: - logger.info("Not close to OpSi reset") + logger.info("[大世界-调度] 未接近大世界重置") return maxsize def handle_after_auto_search(self): - logger.hr("After auto search", level=2) + logger.hr("自动搜索后", level=2) solved = False solved |= self.handle_fleet_emp_debuff() solved |= self.handle_fleet_repair(revert=False) - logger.info(f"Handle after auto search finished, solved={solved}") + logger.info(f"[大世界-搜索] 处理自动搜索完成后, 已解决={solved}") return solved def cl1_ap_preserve(self): @@ -809,7 +852,7 @@ def cl1_ap_preserve(self): keys="OpsiHazard1Leveling.OpsiHazard1Leveling.MinimumActionPointReserve", default=200, ) - logger.info(f"Keep {preserve} AP when CL1 available") + logger.info(f"[大世界-调度] CL1可用时保留 {preserve} 行动点") if not self.action_point_check(preserve): self.config.opsi_task_delay(cl1_preserve=True) self.config.task_stop() @@ -828,11 +871,11 @@ def on_auto_search_battle_count_reset(self): def on_auto_search_battle_count_add(self): self._auto_search_battle_count += 1 - logger.attr("battle_count", self._auto_search_battle_count) + logger.attr("战斗计数", self._auto_search_battle_count) if getattr(self, "is_running_cl1_leveling", False): try: self._cl1_auto_search_battle_count += 1 - logger.attr("cl1_battle_count", self._cl1_auto_search_battle_count) + logger.attr("CL1战斗计数", self._cl1_auto_search_battle_count) # CL1 回合计时使用自己的计数器,而非共享的自动搜索计数器, # 因为其他任务可能复用此循环。 self._auto_search_round_timer = record_cl1_auto_search_battle( @@ -849,7 +892,7 @@ def on_auto_search_battle_count_add(self): ): try: self._meow_auto_search_battle_count += 1 - logger.attr("meow_battle_count", self._meow_auto_search_battle_count) + logger.attr("指挥喵战斗计数", self._meow_auto_search_battle_count) # 耄耋相接记录原始战斗数和标准化轮数; # 指标助手负责危险等级转换。 self._meow_battle_timer = record_meow_auto_search_battle( @@ -964,7 +1007,7 @@ def os_auto_search_daemon( out: AUTO_SEARCH_OS_MAP_OPTION_OFF 且 info_bar_count() >= 2(地图上无可清理对象时)。 AUTO_SEARCH_REWARD(获得自动搜索奖励时)。 """ - logger.hr("OS auto search", level=2) + logger.hr("大世界自动搜索", level=2) self.on_auto_search_battle_count_reset() unlock_checked = False unlock_check_timer = Timer(5, count=10).start() @@ -995,7 +1038,7 @@ def false_func(*args, **kwargs): self.device.stuck_record_clear() if not success: if died_timer.reached(): - logger.warning("Fleet died confirm") + logger.warning("[大世界-战斗] 舰队阵亡确认") break else: if not interrupt_confirm and is_interrupt(): @@ -1036,7 +1079,7 @@ def false_func(*args, **kwargs): and self.config.task_switched() ): if self.config.task.command == "OpsiMeowfficerFarming": - logger.info("Short meow search is running, delay task switch until search finished") + logger.info("[大世界-搜索] 短时指挥喵搜索运行中,延迟任务切换直到搜索完成") else: self.interrupt_auto_search() if interrupt_confirm: @@ -1051,7 +1094,7 @@ def false_func(*args, **kwargs): and not self.config.OpsiHazard1Leveling_SkipHpCheck ): success = False - logger.warning("Fleet died, stop auto search") + logger.warning("[大世界-战斗] 舰队阵亡,停止自动搜索") auto_search_time_limit_timer.reset() continue auto_search_time_limit_timer.reset() @@ -1080,7 +1123,7 @@ def interrupt_auto_search( in: 任意页面,通常为 is_combat_executing out: page_main 或 IN_MAP """ - logger.info("Interrupting auto search") + logger.info("[大世界-搜索] 中断自动搜索") is_loading = False pause_interval = Timer(0.5, count=1) in_main_timer = Timer(3, count=6) @@ -1093,10 +1136,10 @@ def interrupt_auto_search( # 结束条件 if self.is_in_main(): - logger.info("Auto search interrupted") + logger.info("[大世界-搜索] 自动搜索已中断") self.config.task_stop() if not goto_main and self.is_in_map() and in_map_timer.reached(): - logger.info("Auto search interrupted") + logger.info("[大世界-搜索] 自动搜索已中断") if end_task: self.config.task_stop() return @@ -1145,7 +1188,7 @@ def interrupt_auto_search( continue # page_main 的随机背景可能触发 EXP_INFO_*,不检查它们 if in_main_timer.reached(): - logger.info("handle_exp_info") + logger.info("[大世界-信息] 处理经验信息") if self.handle_battle_status(): continue if self.handle_exp_info(): @@ -1177,7 +1220,7 @@ def os_auto_search_run(self, drop=None, strategic=False, interrupt=None): ) finished_combat += combat except CampaignEnd: - logger.info("OS auto search finished") + logger.info("[大世界-搜索] 大世界自动搜索完成") finally: backup.recover() @@ -1241,16 +1284,16 @@ def clear_question(self, drop=None): Returns: bool: 是否清理了问号。 """ - logger.hr("Clear question", level=2) + logger.hr("清除问号", level=2) for _ in range(3): grid = self.radar.predict_question( self.device.image, in_port=self.zone.is_port ) if grid is None: - logger.info("No question mark above current fleet on this radar") + logger.info("[大世界-搜索] 此雷达上当前舰队上方无问号") return False - logger.info(f"Found question mark on {grid}") + logger.info(f"[大世界-搜索] 在 {grid} 找到问号") self.handle_info_bar() self.update_os() @@ -1301,7 +1344,7 @@ def clear_question(self, drop=None): # 检测选择的模式 siren_mode = getattr(self, "siren_device_mode", None) - logger.attr("Siren_device_mode", siren_mode) + logger.attr("塞壬装置模式", siren_mode) # 如果选择了敌人模式 if siren_mode == "enemy": @@ -1356,8 +1399,8 @@ def clear_question(self, drop=None): return True logger.warning( - "Failed to goto question mark after 5 trail, " - "this might be 2 adjacent fleet mechanism, stopped" + "[大世界-地图] 前往问号5次尝试失败, " + "可能是相邻双舰队机关, 已停止" ) return False @@ -1386,7 +1429,7 @@ def run_auto_search( rescan = "full" self.handle_ash_beacon_attack() - logger.info(f"Run auto search, question={question}, rescan={rescan}") + logger.info(f"[大世界-搜索] 运行自动搜索, 问号={question}, 重新扫描={rescan}") finished_combat = 0 with self.stat.new( genre=inflection.underscore(self.config.task.command), @@ -1437,7 +1480,7 @@ def run_strategic_search(self): """ self.handle_ash_beacon_attack() - logger.hr("Run strategy search", level=2) + logger.hr("运行策略搜索", level=2) with self.stat.new( genre=inflection.underscore(self.config.task.command), @@ -1459,7 +1502,7 @@ def run_strategic_search(self): # 任务切换和恢复型异常必须交给上层调度器处理。 raise except Exception as e: - logger.warning(f"Strategic search interrupted: {e}") + logger.warning(f"[大世界-搜索] 策略搜索中断: {e}") return False finally: if drop.count <= 1: @@ -1482,7 +1525,7 @@ def map_rescan_current(self, drop=None, clicked_grids=None): and grids[0].is_exploration_reward ): grid = grids[0] - logger.info(f"Found exploration reward on {grid}") + logger.info(f"[大世界-搜索] 在 {grid} 找到探索奖励") result = self.wait_until_walk_stable( drop=drop, walk_out_of_step=False, confirm_timer=Timer(1.5, count=4) ) @@ -1494,7 +1537,7 @@ def map_rescan_current(self, drop=None, clicked_grids=None): grids = self.view.select(is_akashi=True) if "is_akashi" not in self._solved_map_event and grids and grids[0].is_akashi: grid = grids[0] - logger.info(f"Found Akashi on {grid}") + logger.info(f"[大世界-搜索] 在 {grid} 找到明石") fleet = self.convert_radar_to_local((0, 0)) if fleet.distance_to(grid) > 1: self.device.click(grid) @@ -1514,7 +1557,7 @@ def map_rescan_current(self, drop=None, clicked_grids=None): grid = grids[0] fleet = self.convert_radar_to_local((0, 0)) if fleet.distance_to(grid) <= 1: - logger.info(f"Akashi ({grid}) is near current fleet ({fleet})") + logger.info(f"[大世界-搜索] 明石 ({grid}) 靠近当前舰队 ({fleet})") self.handle_akashi_supply_buy(grid) self._solved_map_event.add("is_akashi") return True @@ -1523,10 +1566,10 @@ def map_rescan_current(self, drop=None, clicked_grids=None): self._execute_fixed_patrol_scan(ExecuteFixedPatrolScan=True) return False else: - logger.info("No map event") + logger.info("[大世界-事件] 无地图事件") return False else: - logger.info(f"Akashi ({grid}) is near current fleet ({fleet})") + logger.info(f"[大世界-搜索] 明石 ({grid}) 靠近当前舰队 ({fleet})") self.handle_akashi_supply_buy(grid) self._solved_map_event.add("is_akashi") return True @@ -1569,7 +1612,7 @@ def map_rescan_current(self, drop=None, clicked_grids=None): if getattr(self, "is_siren_device_confirmed", False): # 检测选择的模式 siren_mode = getattr(self, "siren_device_mode", None) - logger.attr("Siren_device_mode", siren_mode) + logger.attr("塞壬装置模式", siren_mode) # 如果选择了敌人模式 if siren_mode == "enemy": @@ -1634,7 +1677,7 @@ def map_rescan_current(self, drop=None, clicked_grids=None): and grids[0].is_logging_tower ): grid = grids[0] - logger.info(f"Found logging tower on {grid}") + logger.info(f"[大世界-搜索] 在 {grid} 找到记录塔") self.device.click(grid) with self.config.temporary(STORY_ALLOW_SKIP=False): result = self.wait_until_walk_stable( @@ -1653,22 +1696,22 @@ def map_rescan_current(self, drop=None, clicked_grids=None): and grids[0].is_fleet_mechanism ): grid = grids[0] - logger.info(f"Found fleet mechanism on {grid}") + logger.info(f"[大世界-搜索] 在 {grid} 找到舰队机关") self.device.click(grid) self.wait_until_walk_stable( drop=drop, walk_out_of_step=False, confirm_timer=Timer(1.5, count=4) ) if self._solved_fleet_mechanism: - logger.info("All fleet mechanism are solved") + logger.info("[大世界-搜索] 所有舰队机关已解决") self.os_auto_search_run(drop=drop) self._solved_map_event.add("is_fleet_mechanism") return True - logger.info("One of the fleet mechanism is solved") + logger.info("[大世界-搜索] 一个舰队机关已解决") self._solved_fleet_mechanism = True return True - logger.info("No map event") + logger.info("[大世界-事件] 无地图事件") return False def map_rescan_once(self, rescan_mode="full", drop=None): @@ -1683,7 +1726,7 @@ def map_rescan_once(self, rescan_mode="full", drop=None): result = False # 先尝试当前摄像机 - logger.hr("Map rescan current", level=2) + logger.hr("重新扫描当前地图", level=2) self.map_data_init(map_=None) self.handle_info_bar() try: @@ -1691,20 +1734,20 @@ def map_rescan_once(self, rescan_mode="full", drop=None): except MapDetectionError: # 地图可能已清理完毕,单应性变换无法检测到有效格子 logger.warning( - "MAP RESCAN CURRENT Homography failed (score below 0.8), " - "map may be cleared or detection is unstable, unhandled events may be missed" + "[大世界-扫描] 当前地图重新扫描单应性变换失败 (分数低于0.8), " + "地图可能已清理或检测不稳定, 可能遗漏未处理的事件" ) return False if self.map_rescan_current(drop=drop): - logger.info("Map rescan once end, result=True") + logger.info("[大世界-扫描] 地图重新扫描一次结束, 结果=True") return True if rescan_mode == "full": - logger.hr("Map rescan full", level=2) + logger.hr("完全重新扫描地图", level=2) self.map_init(map_=None) queue = self.map.camera_data while len(queue) > 0: - logger.hr(f"Map rescan {queue[0]}") + logger.hr(f"重新扫描 {queue[0]}") queue = queue.sort_by_camera_distance(self.camera) self.focus_to(queue[0], swipe_limit=(6, 5)) self.focus_to_grid_center(0.3) @@ -1714,12 +1757,12 @@ def map_rescan_once(self, rescan_mode="full", drop=None): break queue = queue[1:] - logger.info(f"Map rescan once end, result={result}") + logger.info(f"[大世界-扫描] 地图重新扫描一次结束, 结果={result}") return result def map_rescan(self, rescan_mode="full", drop=None): if self.zone.is_port: - logger.info("Current zone is a port, do not need rescan") + logger.info("[大世界-扫描] 当前区域是港口,无需重新扫描") return False for _ in range(5): @@ -1728,18 +1771,18 @@ def map_rescan(self, rescan_mode="full", drop=None): else: self.fleet_set(self.get_second_fleet()) if not self.is_in_task_explore and len(self._solved_map_event): - logger.info("Solved a map event and not in OpsiExplore, stop rescan") - logger.attr("Solved_map_event", self._solved_map_event) + logger.info("[大世界-扫描] 解决了地图事件且不在大世界探索中,停止重新扫描") + logger.attr("已解决地图事件", self._solved_map_event) self.fleet_set(self.config.OpsiFleet_Fleet) return False result = self.map_rescan_once(rescan_mode=rescan_mode, drop=drop) if not result: - logger.attr("Solved_map_event", self._solved_map_event) + logger.attr("已解决地图事件", self._solved_map_event) self.fleet_set(self.config.OpsiFleet_Fleet) return True - logger.attr("Solved_map_event", self._solved_map_event) - logger.warning("Too many trial on map rescan, stop") + logger.attr("已解决地图事件", self._solved_map_event) + logger.warning("[大世界-扫描] 地图重新扫描尝试过多,停止") self.fleet_set(self.config.OpsiFleet_Fleet) return False @@ -1974,7 +2017,7 @@ def _execute_fixed_patrol_scan( if not ExecuteFixedPatrolScan: logger.info("[大世界] ExecuteFixedPatrolScan 未启用,跳过强制移动。") return - logger.attr("ExecuteFixedPatrolScan", True) + logger.attr("执行固定巡逻扫描", True) self.map_init(map_=None) if not hasattr(self, "map") or not self.map.grids: diff --git a/module/os/map_base.py b/module/os/map_base.py index 3cefb43b4..847b01fc5 100644 --- a/module/os/map_base.py +++ b/module/os/map_base.py @@ -1,9 +1,30 @@ +"""大世界地图基础数据结构。 + +定义大世界(Operation Siren)的地图数据模型。 +大世界的地图使用 OSCampaignMap 类,继承自主线战役的 CampaignMap, +但有以下差异: +- 使用 OSGridInfo 而非 GridInfo 作为格子类型 +- 相机视野范围不同(camera_sight = (-4, -1, 3, 3)) +- 地图尺寸通过节点名称动态设置 +- 所有格子默认权重为 10 + +继承自 CampaignMap,复用寻路和地图管理的核心逻辑。 +""" + from module.base.utils import * from module.map.map_base import CampaignMap, camera_2d from module.map_detection.os_grid import OSGridInfo class OSCampaignMap(CampaignMap): + """大世界地图数据结构。 + + 管理大世界海域的格子信息和寻路逻辑。 + + Attributes: + camera_sight (tuple): 相机视野范围。 + camera_data (list): 相机位置数据列表。 + """ def __init__(self, name=None): super().__init__(name) self.camera_sight = (-4, -1, 3, 3) diff --git a/module/os/map_data.py b/module/os/map_data.py index 3d76f955f..1352db01e 100644 --- a/module/os/map_data.py +++ b/module/os/map_data.py @@ -1,3 +1,14 @@ +"""大世界海域数据字典。 + +存储所有大世界海域的基础信息,由 dev_tools/os_extract.py 自动生成。 +包含每个海域的:ID、地图形状、危险等级、多语言名称、 +区域坐标、偏移坐标和区域编号。 + +包含港口(NY City、Liverpool、Gibraltar 等)和各类作战海域 +(加勒比海、NA 海域、洋脊、大陆架等)的数据。 +禁止手动修改,需通过提取工具重新生成。 +""" + # This file was automatically generated by dev_tools/os_extract.py. # Don't modify it manually. diff --git a/module/os/map_fleet_selector.py b/module/os/map_fleet_selector.py index 1c6ff26cb..c9827e610 100644 --- a/module/os/map_fleet_selector.py +++ b/module/os/map_fleet_selector.py @@ -1,3 +1,12 @@ +"""大世界舰队选择器模块。 + +管理大世界地图中的舰队选择操作。 +大世界支持 4 支舰队(而非主线的 2 支), +通过下拉菜单在舰队间切换。 + +FleetOperator 的大世界版本,适配大世界特有的 UI 布局。 +""" + from module.base.decorator import cached_property from module.base.timer import Timer from module.base.utils import * @@ -8,8 +17,15 @@ class FleetSelector: - """ - Similar to FleetOperator. + """大世界舰队选择器。 + + 管理大世界地图中的舰队切换操作。 + + Attributes: + FLEET_BAR_SHAPE_Y (int): 舰队选择条的高度像素。 + FLEET_BAR_MARGIN_Y (int): 舰队选择条的间距像素。 + FLEET_BAR_ACTIVE_STD (int): 活跃状态的标准差阈值。 + FLEET_LIST (list): 可选舰队列表(FLEET_1 到 FLEET_4)。 """ FLEET_BAR_SHAPE_Y = 42 FLEET_BAR_MARGIN_Y = 11 @@ -34,7 +50,7 @@ def get(self): if self.main.appear(button, offset=(20, 20), similarity=0.75): return index + 1 - logger.info('Unknown OpSi fleet') + logger.info('[大世界-舰队选择] 未知的大世界舰队') return 0 def bar_opened(self): @@ -61,7 +77,7 @@ def parse_fleet_bar(self, image): if np.std(mean, ddof=1) > self.FLEET_BAR_ACTIVE_STD: result.append(4 - index) - logger.info(f'Current selected: {result}') + logger.info(f'[大世界-舰队选择] 当前选择: {result}') return result def selected(self): @@ -179,15 +195,15 @@ def ensure_to_be(self, index): current = self.get() if current == index: - logger.info(f'It is fleet {index} already') + logger.info(f'[大世界-舰队选择] 当前已是舰队 {index}') return False elif current > 0: - logger.info(f'Ensure fleet to be {index}') + logger.info(f'[大世界-舰队选择] 切换到舰队 {index}') self.open() self.click(index) return True - logger.warning('Unknown OpSi fleet, use current fleet instead') + logger.warning('[大世界-舰队选择] 未知的大世界舰队, 使用当前舰队') return False class StorageFleetSelector(FleetSelector): diff --git a/module/os/map_operation.py b/module/os/map_operation.py index fb5071af9..145ea3114 100644 --- a/module/os/map_operation.py +++ b/module/os/map_operation.py @@ -1,3 +1,26 @@ +""" +大世界海域操作模块。 + +负责大世界(Operation Siren)模式下的海域层面操作,包括海域名称识别、 +当前海域检测、海域初始化、特殊海域退出等功能。 + +主要类: + OSMapOperation: 海域操作类,整合命令处理器、任务处理器、港口处理器等。 + +功能: + - 多服务器海域名称 OCR 识别(CN/EN/JP/TW),包含大量服务器特有的 OCR 修正逻辑。 + - 海域初始化:处理弹窗、动画延迟、海域名称识别。 + - 特殊海域(隐秘海域、深渊海域、要塞)的进入和退出。 + - 指挥喵搜索状态检测和进度读取。 + +术语: + 海域 (Zone): 大世界地图上的一个可进入区域,有安全区/危险区等类型。 + 隐秘海域 (Obscure Zone): 特殊海域类型,需要特定条件才能进入。 + 深渊海域 (Abyssal Zone): 特殊海域类型,包含强力敌人。 + 要塞 (Stronghold): 塞壬要塞,特殊海域类型。 + 安全区 (Safe Zone): 已完全清理的海域。 + 危险区 (Danger Zone): 未完全清理的海域。 +""" from module.base.decorator import Config from module.base.timer import Timer from module.base.utils import * @@ -16,6 +39,19 @@ def _remove_zone_suffix(name, suffixes, trim_chars=''): + """从海域名称中移除后缀和尾部字符。 + + 先移除尾部指定字符,再匹配并移除已知后缀。 + 用于多服务器的海域名称标准化。 + + Args: + name (str): 原始海域名称。 + suffixes (tuple[str, ...]): 要移除的后缀列表。 + trim_chars (str): 要从尾部移除的字符集合。 + + Returns: + str: 处理后的海域名称。 + """ while trim_chars and any(name.endswith(char) for char in trim_chars): name = name[:-1] for suffix in suffixes: @@ -25,6 +61,15 @@ def _remove_zone_suffix(name, suffixes, trim_chars=''): class OSMapOperation(MapOrderHandler, MissionHandler, PortHandler, StorageHandler, OSFleetSelector): + """大世界海域操作类。 + + 整合地图命令处理器、任务处理器、港口处理器、仓库处理器和舰队选择器, + 提供海域层面的完整操作能力。 + + Attributes: + zone (Zone): 当前海域对象。 + is_zone_name_hidden (bool): 海域名称是否被安全区标记遮挡。 + """ zone: Zone is_zone_name_hidden = False @@ -188,16 +233,20 @@ def get_current_zone(self): ScriptError: 脚本错误时抛出。 """ name = self.get_zone_name() - logger.info(f'Map name processed: {name}') + logger.info(f'[大世界-地图操作] 地图名称已处理: {name}') try: self.zone = self.name_to_zone(name) except ScriptError as e: raise MapDetectionError(*e.args) from e - logger.attr('Zone', self.zone) + logger.attr('海域', self.zone) self.zone_config_set() return self.zone def zone_config_set(self): + """根据当前海域区域设置地图检测参数。 + + 区域 5(极地海域)使用不同的边缘颜色范围和角落检测方向。 + """ if self.zone.region == 5: self.config.HOMO_EDGE_COLOR_RANGE = (0, 8) self.config.MAP_ENSURE_EDGE_INSIGHT_CORNER = 'bottom' @@ -219,9 +268,9 @@ def zone_init(self, fallback_init=True): Raises: MapDetectionError: 解析海域名称失败时抛出。 """ - logger.hr('Zone init') + logger.hr('[大世界-地图操作] 区域初始化') self.wait_os_map_buttons() - logger.info('Get zone name') + logger.info('[大世界-地图操作] 获取区域名称') timeout = Timer(1.5, count=5).start() for _ in self.loop(): # 处理弹窗 @@ -248,7 +297,7 @@ def zone_init(self, fallback_init=True): continue if timeout.reached(): - logger.warning('Zone init timeout') + logger.warning('[大世界-地图操作] 区域初始化超时') break if self.is_in_map(): try: @@ -259,13 +308,13 @@ def zone_init(self, fallback_init=True): timeout.reset() if fallback_init: - logger.warning('Unable to get zone name, get current zone from globe map instead') + logger.warning('[大世界-地图] 无法获取区域名称,从地球仪获取当前区域') if hasattr(self, 'get_current_zone_from_globe'): return self.get_current_zone_from_globe() else: - logger.warning('OperationSiren.get_current_zone_from_globe() not exists') + logger.warning('[大世界-地图] OperationSiren.get_current_zone_from_globe() 不存在') if not self.is_in_map(): - logger.warning('Trying to get zone name, but not in OS map') + logger.warning('[大世界-地图操作] 尝试获取区域名称,但不在大世界地图中') return self.get_current_zone() def is_in_special_zone(self): @@ -283,7 +332,7 @@ def map_exit(self): in: is_in_map out: is_in_map, 来源海域 """ - logger.hr('Map exit') + logger.hr('[大世界-地图操作] 地图退出') confirm_timer = Timer(1, count=2) changed = False for _ in self.loop(): diff --git a/module/os/operation_siren.py b/module/os/operation_siren.py index a81ad5c12..c052b697f 100644 --- a/module/os/operation_siren.py +++ b/module/os/operation_siren.py @@ -1,3 +1,27 @@ +"""大世界主任务编排模块。 + +组合大世界的所有任务模块,提供统一的任务执行入口。 +大世界(Operation Siren)是碧蓝航线的开放世界模式, +包含多种任务类型: + +- 日常任务(OpsiDaily):每日固定任务 +- 商店(OpsiShop):港口商店购买 +- 代币兑换(OpsiVoucher):代币商店 +- 指挥喵 farming(OpsiMeowfficerFarming) +- 危险海域升级(OpsiHazard1Leveling) +- 舰队自动切换(OpsiFleetAutoChange) +- 行动力溢出保护(OpsiPreventActionPointOverflow) +- 模糊任务(OpsiObscure) +- 深渊任务(OpsiAbyssal) +- 档案任务(OpsiArchive) +- 要塞任务(OpsiStronghold) +- 月度 Boss(OpsiMonthBoss) +- 探索(OpsiExplore) +- 跨月重置(OpsiCrossMonth) + +继承自所有任务模块,通过多重继承组合各任务的能力。 +""" + from datetime import timedelta from module.config.time_source import now as current_time @@ -42,10 +66,10 @@ def _os_target_exit(self): def os_target_receive(self): next_reset = get_os_next_reset() now = current_time() - logger.attr('OpsiNextReset', next_reset) + logger.attr('大世界下次重置', next_reset) if next_reset - now < timedelta(days=1): - logger.error('Only one day to next reset, received loggers may be wasted.' - 'Running Achievement Collection is undesirable, delayed to next reset.') + logger.error('[大世界-成就] 距离下次重置仅剩一天,领取的成就奖励可能浪费。' + '运行成就收集不太合适,延迟到下次重置。') else: self.os_map_goto_globe(unpin=False) if self.appear(TARGET_RED_DOT): @@ -53,7 +77,7 @@ def os_target_receive(self): OSTargetHandler(self.config, self.device).receive_reward() self._os_target_exit() else: - logger.info('No reward to receive') + logger.info('[大世界-成就] 没有奖励可领取') self.config.OpsiTarget_LastRun = now.replace(microsecond=0) def _os_target(self): diff --git a/module/os/radar.py b/module/os/radar.py index 0c542c3d3..430e72bb1 100644 --- a/module/os/radar.py +++ b/module/os/radar.py @@ -1,3 +1,24 @@ +""" +大世界雷达检测模块。 + +负责大世界(Operation Siren)模式下的雷达系统,通过分析屏幕右上角的 +雷达小地图来识别附近的敌人、资源、港口、问号等对象。 + +主要类: + RadarGrid: 雷达格子数据类,表示雷达上的一个可识别位置。 + Radar: 雷达管理类,管理所有雷达格子并提供预测和查询接口。 + +雷达布局: + 雷达以当前舰队位置为中心,呈圆形分布格子。 + 默认参数: center=(1140, 226), delta=(11.7, 11.7), radius=5.15。 + 每个格子通过颜色检测来判断其类型(敌人/资源/港口等)。 + +术语: + 雷达 (Radar): 大世界地图右上角的小地图,显示附近的对象。 + 塞壬 (Siren): 大世界中的敌对势力,在雷达上显示为红色标记。 + 明石 (Akashi): 大世界中的隐藏商店,显示为白色问号。 + 指挥喵 (Meowfficer): 大世界中的辅助系统,显示为蓝色标记。 +""" from module.base.mask import Mask from module.base.utils import * from module.config.config import AzurLaneConfig @@ -9,6 +30,27 @@ class RadarGrid: + """雷达格子数据类。 + + 表示雷达小地图上的一个位置,通过颜色检测来判断其类型。 + 每个格子可以是敌人、资源、港口、问号等不同类型。 + + Attributes: + location (tuple[int, int]): 格子相对于雷达中心的坐标,如 (3, 2)。 + center (tuple[int, int]): 格子中心在截图中的像素坐标。 + is_enemy (bool): 是否为敌人(红色枪标记)。 + is_resource (bool): 是否为资源(绿色箱子)。 + is_exclamation (bool): 是否为感叹号(黄色 '!')。 + is_meowfficer (bool): 是否为指挥喵(蓝色标记)。 + is_question (bool): 是否为问号(白色 '?')。 + is_ally (bool): 是否为友军(黄色 '!',日常任务中的货运船)。 + is_akashi (bool): 是否为明石(白色 '?',隐藏商店)。 + is_archive (bool): 是否为档案(紫色标记)。 + is_port (bool): 是否为港口。 + is_fleet (bool): 是否为当前舰队位置(坐标原点)。 + enemy_scale (int): 敌人规模(0=未知,1=轻型,2=主力,3=航母)。 + enemy_genre (str): 敌人类型描述,如 'Light'、'Main'、'Carrier'。 + """ is_enemy = False # Red gun is_resource = False # green box to get items is_exclamation = False # Yellow exclamation mark '!' @@ -146,6 +188,20 @@ def predict_archive(self): class Radar: + """雷达管理类。 + + 管理大世界地图右上角雷达小地图的所有格子,提供预测、查询和 + 港口定位等功能。格子以圆形分布,中心为当前舰队位置。 + + Attributes: + grids (dict[tuple, RadarGrid]): 雷达格子字典,键为 (x, y) 坐标。 + center_loca (tuple[int, int]): 雷达中心位置(当前舰队)。 + port_loca (tuple): 上次检测到的港口位置。 + config (AzurLaneConfig): 配置对象。 + center (tuple[int, int]): 雷达在截图中的像素中心点。 + delta (tuple[float, float]): 格子间的像素间距。 + shape (list[list[int]]): 雷达网格的形状范围。 + """ grids: dict center_loca = (0, 0) port_loca = (0, 0) @@ -209,8 +265,8 @@ def predict(self, image): for port in self.select(is_port=True): for grid in self.select(is_question=True): if np.sum(np.abs(np.subtract(port.location, grid.location))) == 1: - logger.warning(f'Wrong radar prediction is_question {grid.location} {grid.encode()} ' - f'near {port.location} {port.encode()}') + logger.warning(f'[大世界-雷达] 雷达预测错误 is_question {grid.location} {grid.encode()} ' + f'靠近 {port.location} {port.encode()}') grid.is_question = False def select(self, **kwargs): diff --git a/module/os/sea_miles_ocr.py b/module/os/sea_miles_ocr.py index 25a722df8..851dde4fb 100644 --- a/module/os/sea_miles_ocr.py +++ b/module/os/sea_miles_ocr.py @@ -1,9 +1,21 @@ +"""海域里程 OCR 识别模块。 + +识别大世界中的海域里程(Sea Miles)数值。 +海域里程是大世界的特殊货币,用于在港口商店购买物品。 + +继承自 Digit,复用数字 OCR 识别能力。 +""" + from module.logger import logger from module.ocr.ocr import Digit from module.os_handler.assets import MARITIME_SCHEDULE class SeaMilesOCR(Digit): + """海域里程 OCR 识别器。 + + 识别范围 0-100000000,超出范围时返回 0。 + """ def __init__(self): super().__init__( buttons=MARITIME_SCHEDULE, @@ -17,7 +29,7 @@ def __init__(self): def after_process(self, result): result = super().after_process(result) if not (0 <= result <= 100000000): - logger.warning(f"Abnormal sea miles: {result}") + logger.warning(f"[大世界-里程] 异常的海域里程: {result}") return 0 return result diff --git a/module/os/ship_exp.py b/module/os/ship_exp.py index 470c21ca0..1adc0ee99 100644 --- a/module/os/ship_exp.py +++ b/module/os/ship_exp.py @@ -1,3 +1,16 @@ +"""舰船经验识别模块。 + +通过 OCR 识别舰船的等级和经验值,用于大世界中的经验监控。 + +ShipLevel: 识别舰船等级(1-125),超出范围时返回 0。 +ShipExp: 识别舰船经验值,格式为 "当前经验/升级所需经验"。 + +OCR 修正规则: +- I -> 1, D -> 0, S -> 5, B -> 8(常见 OCR 误识别) + +继承自 Digit/Ocr,复用 OCR 基础设施。 +""" + import re from module.awaken.assets import OCR_SHIP_LEVEL, OCR_SHIP_EXP @@ -7,15 +20,25 @@ class ShipLevel(Digit): + """舰船等级 OCR 识别器。 + + 识别舰船等级,范围为 1-125。 + """ + def after_process(self, result): result = super().after_process(result) if result < 1 or result > 125: - logger.warning('Unexpected ship level') + logger.warning('[大世界-经验] 意外的舰船等级') result = 0 return result class ShipExp(Ocr): + """舰船经验值 OCR 识别器。 + + 识别舰船经验值,格式为 "当前/所需"。 + """ + def __init__(self, buttons, lang='azur_lane', letter=(255, 255, 255), threshold=64, alphabet='0123456789IDSBM/', name=None): super().__init__(buttons, lang=lang, letter=letter, threshold=threshold, alphabet=alphabet, name=name) @@ -46,7 +69,7 @@ def ocr(self, image, direct_ocr=False): current = int(result[0]) return current else: - logger.warning(f'Unexpected ocr result: {result_list}') + logger.warning(f'[大世界-经验] 意外的OCR结果: {result_list}') return 0 def ship_info_get_level_exp(main, skip_first_screenshot=True): @@ -80,4 +103,4 @@ def ship_info_get_level_exp(main, skip_first_screenshot=True): if exp > 0: return level, exp else: - continue \ No newline at end of file + continue diff --git a/module/os/ship_exp_data.py b/module/os/ship_exp_data.py index 852f83591..d3a0f9e3c 100644 --- a/module/os/ship_exp_data.py +++ b/module/os/ship_exp_data.py @@ -1,3 +1,14 @@ +"""舰船经验数据表。 + +存储舰船从 1 级到满级所需累计经验值的查找表。 +由 dev_tools/ship_exp_extract.py 自动生成,用于计算 +舰船当前等级和升级所需经验值。 + +在大世界舰队自动更换功能中,配合 ShipExp OCR 使用, +通过经验差值判断旗舰是否已升级,触发舰队更换逻辑。 +禁止手动修改。 +""" + # This file is auto-generated by dev_tools/ship_exp_extract.py. # Do not edit this file manually. diff --git a/module/os/tasks/abyssal.py b/module/os/tasks/abyssal.py index fd3453d7b..0628e5aad 100644 --- a/module/os/tasks/abyssal.py +++ b/module/os/tasks/abyssal.py @@ -1,4 +1,16 @@ -from datetime import timedelta +"""大世界深渊海域任务模块。 + +执行大世界深渊海域(Abyssal)的清理挑战,包括: +- 深渊坐标的获取和进入 +- 潜艇配置检测和自动编队 +- 多层深渊的逐层清理 +- 行动力保护和代币资源管理 + +继承自 CoinTaskMixin 和 OSMap,提供代币保护和地图导航能力, +深渊海域是大世界中高难度的战斗内容,奖励丰厚。 +""" + +from datetime import timedelta from module.exception import RequestHumanTakeover from module.config.time_source import now as current_time @@ -86,7 +98,7 @@ def _delay_until_submarine_cooldown_end(self, cooldown_end_time): self._smart_scheduling_no_content_task = 'OpsiAbyssal' return - logger.hr('Submarine cooldown detected', level=1) + logger.hr('检测到潜艇冷却', level=1) logger.info(f'[大世界-深渊坐标] 潜艇冷却结束时间:{cooldown_end_time}') logger.info('[大世界-深渊坐标] 延时深渊坐标任务到潜艇冷却结束') diff --git a/module/os/tasks/archive.py b/module/os/tasks/archive.py index 0d78e8eec..85ff99914 100644 --- a/module/os/tasks/archive.py +++ b/module/os/tasks/archive.py @@ -1,3 +1,13 @@ +""" +档案任务模块。 + +执行大世界档案坐标任务:先完成已持有的档案坐标,再通过白票商店 +购买新的档案坐标,循环执行直到资源耗尽。建议每周运行一次, +开发团队通常在维护后更新新档案数据。 + +Classes: + OpsiArchive: 档案任务处理器,继承 OSMap。 +""" from module.config.utils import get_nearest_weekday_date from module.logger import logger @@ -35,5 +45,5 @@ def os_archive(self): # 延迟到最近的周三重置 next_reset = get_nearest_weekday_date(target=2) logger.info('档案坐标已全部完成,延迟到下次重置') - logger.attr('OpsiNextReset', next_reset) + logger.attr('大世界下次重置', next_reset) self.config.task_delay(target=next_reset) diff --git a/module/os/tasks/coin_task_mixin.py b/module/os/tasks/coin_task_mixin.py index f6af75c86..07e62b3d0 100644 --- a/module/os/tasks/coin_task_mixin.py +++ b/module/os/tasks/coin_task_mixin.py @@ -1,3 +1,14 @@ +"""大世界代币任务 Mixin 模块。 + +从 scheduling 模块重新导出 CoinTaskMixin 类。 +CoinTaskMixin 提供大世界代币任务的通用逻辑,包括: +- 代币(作战补给凭证)资源检查和保护 +- 任务无内容时的延迟处理 +- 与智能调度系统的协作 + +被多个大世界任务模块继承使用。 +""" + from module.os.tasks.scheduling import CoinTaskMixin __all__ = ['CoinTaskMixin'] diff --git a/module/os/tasks/cross_month.py b/module/os/tasks/cross_month.py index 421264e8c..fbc29b097 100644 --- a/module/os/tasks/cross_month.py +++ b/module/os/tasks/cross_month.py @@ -1,3 +1,15 @@ +"""大世界跨月重置模块。 + +处理大世界每月重置前后的过渡逻辑,包括: +- 重置时间检测和倒计时管理 +- 重置前 10 分钟内的紧急操作 +- 指定海域的快速清理 +- 跨月期间其他任务的延迟协调 + +继承自 MeowfficerTargetZoneMixin 和 OSMap, +确保在月度重置前完成关键任务,避免资源损失。 +""" + from datetime import timedelta from module.config.time_source import now as current_time @@ -17,7 +29,7 @@ def os_cross_month_end(self): def os_cross_month(self): next_reset = get_os_next_reset() now = current_time() - logger.attr('OpsiNextReset', next_reset) + logger.attr('大世界下次重置', next_reset) # 检查开始时间 if next_reset < now: @@ -33,7 +45,7 @@ def os_cross_month(self): logger.hr('跨月每日等待大世界重置', level=1) logger.warning('AzurPilot 正在等待下次大世界重置,等待期间请不要操作游戏') while True: - logger.info(f'Wait until {next_reset}') + logger.info(f'等待到 {next_reset}') now = current_time() remain = (next_reset - now).total_seconds() if remain <= 0: diff --git a/module/os/tasks/daily.py b/module/os/tasks/daily.py index a51f7b065..cdefd71f0 100644 --- a/module/os/tasks/daily.py +++ b/module/os/tasks/daily.py @@ -1,3 +1,14 @@ +""" +大世界每日任务模块。 + +遍历所有碧蓝港口执行每日港口任务,包括接取/完成任务委托、 +修理舰队、补给物资等。支持随机化港口遍历顺序以降低检测风险。 +任务完成后自动延迟至次日服务器更新时间。 + +Classes: + OpsiDaily: 大世界每日任务处理器,继承 OSMap。 +""" + import numpy as np from module.config.config import TaskEnd @@ -192,10 +203,10 @@ def os_daily(self): self.logger_use() if self.config.OpsiDaily_SkipSirenResearchMission and self.config.SERVER not in ['cn']: - logger.warning(f'OpsiDaily.SkipSirenResearchMission is not supported in {self.config.SERVER}') + logger.warning(f'[大世界-日常] 跳过塞壬研究任务不支持 {self.config.SERVER} 服务器') self.config.OpsiDaily_SkipSirenResearchMission = False if self.config.OpsiDaily_KeepMissionZone and self.config.SERVER not in ['cn']: - logger.warning(f'OpsiDaily.KeepMissionZone is not supported in {self.config.SERVER}') + logger.warning(f'[大世界-日常] 保留任务区域不支持 {self.config.SERVER} 服务器') self.config.OpsiDaily_KeepMissionZone = False skip_siren_mission = self.config.OpsiDaily_SkipSirenResearchMission @@ -217,7 +228,7 @@ def os_daily(self): if self.config.OpsiDaily_KeepMissionZone: if self.zone.is_azur_port: - logger.info('Already in azur port') + logger.info('[大世界-日常] 已在碧蓝港口') else: self.globe_goto(self.zone_nearest_azur_port(self.zone)) self.os_daily_clear_all_mission_zones() diff --git a/module/os/tasks/explore.py b/module/os/tasks/explore.py index f8e3f64ab..caf33def0 100644 --- a/module/os/tasks/explore.py +++ b/module/os/tasks/explore.py @@ -1,3 +1,14 @@ +""" +大世界每月开荒模块。 + +执行大世界海域的全面探索,自动遍历并清理未完成的海域区域。 +探索期间会延迟其他大世界任务(隐秘、深渊、要塞等)以避免冲突。 +记录探索失败的海域 ID,支持从上次中断的位置继续探索。 + +Classes: + OpsiExplore: 每月开荒处理器,继承 OSMap。 +""" + from module.config.utils import get_os_next_reset, DEFAULT_TIME from module.exception import GameStuckError, ScriptError from module.logger import logger @@ -28,7 +39,7 @@ def _os_explore_task_delay(self): keys = f'{task}.Scheduler.NextRun' current = self.config.cross_get(keys=keys, default=DEFAULT_TIME) if current < next_run: - logger.info(f'Delay task `{task}` to {next_run}') + logger.info(f'[大世界-探索] 延迟任务 `{task}` 到 {next_run}') self.config.cross_set(keys=keys, value=next_run) def _os_explore(self): @@ -46,8 +57,8 @@ def _os_explore(self): def end(): logger.info('每月开荒+已完成,延迟到下次重置') next_reset = get_os_next_reset() - logger.attr('OpsiNextReset', next_reset) - logger.info('To run again, clear OpsiExplore.Scheduler.NextRun and set OpsiExplore.OpsiExplore.LastZone=0') + logger.attr('大世界下次重置', next_reset) + logger.info('[大世界-探索] 如需重新运行,请清除 OpsiExplore.Scheduler.NextRun 并设置 OpsiExplore.OpsiExplore.LastZone=0') with self.config.multi_set(): self.config.OpsiExplore_LastZone = 0 self.config.OpsiExplore_ExploreProgress = '已完成百分之100.00' @@ -64,7 +75,7 @@ def end(): try: last_zone = self.name_to_zone(self.config.OpsiExplore_LastZone).zone_id except ScriptError: - logger.warning(f'Invalid OpsiExplore_LastZone={self.config.OpsiExplore_LastZone}, re-explore') + logger.warning(f'[大世界-探索] 无效的 OpsiExplore_LastZone={self.config.OpsiExplore_LastZone}, 重新探索') last_zone = 0 # 从上次探索的区域继续 @@ -75,12 +86,12 @@ def end(): if total_zones > 0: percentage = completed_count / total_zones * 100 self.config.OpsiExplore_ExploreProgress = f'已完成百分之{percentage:.2f}' - logger.info(f'Last zone: {self.name_to_zone(last_zone)}, next zone: {order[:1]}') + logger.info(f'上次区域: {self.name_to_zone(last_zone)}, next zone: {order[:1]}') elif last_zone == 0: completed_count = 0 order = full_order self.config.OpsiExplore_ExploreProgress = '已完成百分之0.00' - logger.info(f'First run, next zone: {order[:1]}') + logger.info(f'首次运行,下一个区域: {order[:1]}') else: raise ScriptError(f'Invalid last_zone: {last_zone}') @@ -117,7 +128,7 @@ def end(): percentage = completed_count / total_zones * 100 self.config.OpsiExplore_ExploreProgress = f'已完成百分之{percentage:.2f}' if finished_combat == 0: - logger.warning('Zone cleared but did not finish any combat') + logger.warning('区域已清除但未完成任何战斗') self._os_explore_failed_zone.append(zone) self.handle_after_auto_search() self.config.check_task_switch() diff --git a/module/os/tasks/fleet_auto_change.py b/module/os/tasks/fleet_auto_change.py index b5d4f8e98..14d2322eb 100644 --- a/module/os/tasks/fleet_auto_change.py +++ b/module/os/tasks/fleet_auto_change.py @@ -1,3 +1,15 @@ +"""大世界舰队自动更换模块。 + +在大世界战斗过程中自动更换旗舰满级的舰队,包括: +- 检测旗舰等级和经验值判断是否需要更换 +- 舰队部署界面的进入、选择和确认操作 +- 支持收藏夹筛选和多舰队槽位管理 +- 舰队装备和编队的完整配置流程 + +通过舰船经验 OCR 检测旗舰升级状态, +当旗舰满级时自动切换到下一组备用舰队继续 farming。 +""" + from datetime import timedelta from module.config.time_source import now as current_time diff --git a/module/os/tasks/hazard_leveling.py b/module/os/tasks/hazard_leveling.py index 9effbc76d..4843ca7ab 100644 --- a/module/os/tasks/hazard_leveling.py +++ b/module/os/tasks/hazard_leveling.py @@ -1,3 +1,15 @@ +"""大世界侵蚀 1 等级提升模块。 + +在危险等级 1 的海域中反复战斗以提升舰船等级,包括: +- 独立运行和智能调度两种模式 +- 作战补给凭证(代币)资源保护检查 +- 舰船经验检测和等级追踪 +- 海域里程 OCR 记录 + +继承自 CoinTaskMixin 和 OSMap,提供代币保护和地图导航能力, +是大世界中最常用的舰船经验 farming 方式。 +""" + from datetime import timedelta from module.base.timer import Timer @@ -205,7 +217,7 @@ def run_hazard1_leveling_once(self, ap_preserve=None): def os_check_leveling(self): """检查大世界阵容练级进度。""" logger.hr("大世界-侵蚀1练级检查", level=1) - logger.attr("OpsiCheckLeveling_LastRun", self.config.OpsiCheckLeveling_LastRun) + logger.attr("大世界危险海域上次运行", self.config.OpsiCheckLeveling_LastRun) check_interval = self.config.OpsiCheckLeveling_CheckInterval if not isinstance(check_interval, int) or check_interval < 1: diff --git a/module/os/tasks/meowfficer_farming.py b/module/os/tasks/meowfficer_farming.py index 32564853e..249882a9b 100644 --- a/module/os/tasks/meowfficer_farming.py +++ b/module/os/tasks/meowfficer_farming.py @@ -1,3 +1,15 @@ +"""大世界指挥喵 farming 模块。 + +在大世界中执行指挥喵(Meowfficer)资源 farming,包括: +- 支持指定目标海域的精确 farming +- 智能海域选择和路径规划 +- 代币资源保护和行动力管理 +- 失败重试和异常恢复机制 + +继承自 CoinTaskMixin 和 OSMap,提供代币保护和地图导航能力, +通过指定海域列表实现高效的指挥喵资源收集。 +""" + from module.config.config import TaskEnd from module.config.utils import get_os_reset_remain from module.exception import ( @@ -102,14 +114,14 @@ def _meow_target_zones(self, *, require_target=False, allow_multiple=True): if errors: self._meow_target_zone_error(f'耄耋相接指定海域输入错误 ({raw_value}): {"; ".join(errors)}') - logger.attr('MeowTargetZones', [zone.zone_id for zone in zones]) + logger.attr('目标海域列表', [zone.zone_id for zone in zones]) return zones def _meow_target_zone_at(self, zones, index): """按顺序循环获取本轮目标海域。""" zone_index = index % len(zones) zone = zones[zone_index] - logger.attr('MeowTargetZoneIndex', f'{zone_index + 1}/{len(zones)}') + logger.attr('目标海域索引', f'{zone_index + 1}/{len(zones)}') return zone, zone_index + 1 @@ -132,7 +144,7 @@ def _meow_ap_check(self, preserve, ap_checked): and self.config.OpsiAshBeacon_EnsureFullyCollected: logger.info('[大世界-耄耋相接] 余烬信标未收集满,暂时忽略行动力限制') self.config.OS_ACTION_POINT_PRESERVE = 0 - logger.attr('OS_ACTION_POINT_PRESERVE', self.config.OS_ACTION_POINT_PRESERVE) + logger.attr('大世界行动力保留', self.config.OS_ACTION_POINT_PRESERVE) if not ap_checked: # 行动力前置检查,确保明日每日任务有足够行动力 diff --git a/module/os/tasks/month_boss.py b/module/os/tasks/month_boss.py index 9be11fef4..6d1474a11 100644 --- a/module/os/tasks/month_boss.py +++ b/module/os/tasks/month_boss.py @@ -1,3 +1,14 @@ +""" +月度Boss任务模块。 + +挑战并击败大世界月度Boss,支持普通和困难两种难度。 +战斗前检查适应性数值以选择合适的难度,战斗后在港口修理舰队。 +每月重置时自动延迟到新的Boss出现周期。 + +Classes: + OpsiMonthBoss: 月度Boss处理器,继承 OSMap。 +""" + import numpy as np from module.config.utils import get_os_next_reset @@ -36,7 +47,7 @@ def clear_month_boss(self): logger.hr("月度Boss预检查", level=2) checkout_offset = self.os_mission_enter( skip_siren_mission=self.config.cross_get('OpsiDaily.OpsiDaily.SkipSirenResearchMission')) - logger.attr('OpsiMonthBoss.Mode', self.config.OpsiMonthBoss_Mode) + logger.attr('OpsiMonthBoss.模式', self.config.OpsiMonthBoss_Mode) if self.appear(OS_MONTHBOSS_NORMAL, offset=checkout_offset): logger.attr('月度Boss难度', 'normal') is_normal = True @@ -60,7 +71,7 @@ def clear_month_boss(self): self.os_map_goto_globe(unpin=False) adaptability = self.get_adaptability() if (np.array(adaptability) < (203, 203, 156)).any(): - logger.info("Adaptability is lower than suppression level, get stronger and come back") + logger.info("[大世界-月度Boss] 适应性低于压制等级,需要变强后再来") self.config.task_delay(server_update=True) self.config.task_stop() # 无需退出,复用当前状态 diff --git a/module/os/tasks/obscure.py b/module/os/tasks/obscure.py index b8f98b189..400878334 100644 --- a/module/os/tasks/obscure.py +++ b/module/os/tasks/obscure.py @@ -1,4 +1,16 @@ -from module.logger import logger +"""大世界隐秘海域任务模块。 + +执行大世界隐秘海域(Obscure Zone)的清理任务,包括: +- 从仓库获取隐秘海域坐标信息 +- 前往目标区域执行自动搜索战斗 +- 行动力不足时的保护和延迟处理 +- 支持强制运行模式 + +继承自 CoinTaskMixin 和 OSMap,提供代币保护和地图导航能力, +隐秘海域是大世界主要的代币获取途径之一。 +""" + +from module.logger import logger from module.os.map import OSMap from module.os.tasks.scheduling import CoinTaskMixin diff --git a/module/os/tasks/prevent_action_point_overflow.py b/module/os/tasks/prevent_action_point_overflow.py index 1544aaf40..4330b7016 100644 --- a/module/os/tasks/prevent_action_point_overflow.py +++ b/module/os/tasks/prevent_action_point_overflow.py @@ -1,3 +1,14 @@ +"""行动力溢出保护模块。 + +防止大世界行动力(AP)因自然回复而溢出浪费,包括: +- 监控行动力自然回复速率(每 10 分钟回复 1 点,上限 200) +- 当行动力即将达到上限时触发其他大世界任务消耗行动力 +- 可配置上下限阈值控制触发时机 + +继承自 OpsiScheduling,与其他大世界任务协调调度, +确保行动力资源得到充分利用。 +""" + from module.config.config import TaskEnd from module.logger import logger from module.os.tasks.scheduling import OpsiScheduling @@ -172,9 +183,9 @@ def run_prevent_action_point_overflow(self): logger.hr('大世界-防止行动力溢出', level=1) upperbound, lowerbound = self._get_prevent_action_point_overflow_thresholds() task_name = self._get_prevent_action_point_overflow_task() - logger.attr('ActionPointUpperbound', upperbound) - logger.attr('ActionPointLowerbound', lowerbound) - logger.attr('OverflowTask', task_name) + logger.attr('行动力上限', upperbound) + logger.attr('行动力下限', lowerbound) + logger.attr('溢出任务', task_name) started = False while True: diff --git a/module/os/tasks/shop.py b/module/os/tasks/shop.py index 6198f8fcb..97cba1447 100644 --- a/module/os/tasks/shop.py +++ b/module/os/tasks/shop.py @@ -1,3 +1,15 @@ +"""大世界港口商店模块。 + +执行大世界港口商店的补给物资购买,包括: +- 遍历所有友方港口购买补给 +- 黄币和紫币余额检查 +- 月度购买限制日期配置 +- 港口间的自动导航和购买执行 + +继承自 OSMap,提供港口导航和商店购买的完整操作链路, +是大世界代币消耗的重要途径之一。 +""" + from datetime import datetime, timedelta from module.config.time_source import now as current_time @@ -36,11 +48,11 @@ def os_shop(self): not_empty = self.handle_port_supply_buy() next_reset = self._os_shop_delay(not_empty) logger.info('大世界商店+已完成,延迟到下次重置') - logger.attr('OpsiShopNextReset', next_reset) + logger.attr('大世界商店下次重置', next_reset) else: next_reset = get_os_next_reset() - logger.warning('There is no shop in the port, skip to the next month.') - logger.attr('OpsiShopNextReset', next_reset) + logger.warning('[大世界-商店] 港口中没有商店,跳到下个月') + logger.attr('大世界商店下次重置', next_reset) self.port_shop_quit() self.port_quit() diff --git a/module/os/tasks/stronghold.py b/module/os/tasks/stronghold.py index ae1c94b8b..d90978c0c 100644 --- a/module/os/tasks/stronghold.py +++ b/module/os/tasks/stronghold.py @@ -46,7 +46,7 @@ def clear_stronghold(self): if self.config.OpsiStronghold_SubmarineEveryCombat: if self.zone.is_azur_port: - logger.info('Already in azur port') + logger.info('[大世界-要塞] 已在碧蓝港口') else: self.globe_goto(self.zone_nearest_azur_port(self.zone)) self.handle_fleet_repair_by_config(revert=False) @@ -103,10 +103,10 @@ def run_stronghold_one_fleet(self, fleet, submarine=False): # 判断结果 if self.get_stronghold_percentage() == '0': - logger.info('BOSS clear') + logger.info('[大世界-要塞] Boss已清除') return True elif any(self.need_repair): - logger.info('Auto search stopped, because fleet died') + logger.info('[大世界-要塞] 自动搜索停止,因为舰队阵亡') # 重新进入以重置舰队位置 prev = self.zone self.globe_goto(self.zone_nearest_azur_port(self.zone)) @@ -114,12 +114,12 @@ def run_stronghold_one_fleet(self, fleet, submarine=False): self.globe_goto(prev, types='STRONGHOLD') return False elif submarine and self.os_sumbarine_empty(): - logger.info('Submarine ammo exhausted, wait for the next clear') + logger.info('[大世界-要塞] 潜艇弹药耗尽,等待下次清理') # 潜艇弹药耗尽,等待下次清理 self.globe_goto(self.zone_nearest_azur_port(self.zone)) return True else: - logger.info('Auto search stopped, because fleet stuck') + logger.info('[大世界-要塞] 自动搜索停止,因为舰队卡住') # 重新进入以重置舰队位置 prev = self.zone self.globe_goto(self.zone_nearest_azur_port(self.zone)) @@ -144,7 +144,7 @@ def run_stronghold(self, submarine=False): logger.hr('塞壬要塞清理', level=1) fleets = self.parse_fleet_filter() for fleet in fleets: - logger.hr(f'Turn: {fleet}', level=2) + logger.hr(f'[大世界-要塞] 回合: {fleet}', level=2) if not isinstance(fleet, BossFleet): self.os_order_execute(recon_scan=False, submarine_call=True) continue diff --git a/module/os/tasks/voucher.py b/module/os/tasks/voucher.py index 9f8aeb48c..e38155d7e 100644 --- a/module/os/tasks/voucher.py +++ b/module/os/tasks/voucher.py @@ -1,3 +1,14 @@ +"""大世界代币商店模块。 + +管理大世界代币商店(白票商店)的购买流程,包括: +- 从全球地图进入代币兑换界面 +- 调用 VoucherShop 执行商品购买 +- 退出商店并返回全球地图 +- 完成后延迟至下次大世界重置 + +继承自 OSMap,提供地图导航和代币商店的完整操作链路。 +""" + from module.config.utils import get_os_next_reset from module.logger import logger from module.os.map import OSMap @@ -25,5 +36,5 @@ def os_voucher(self): next_reset = get_os_next_reset() logger.info('白票商店已完成,延迟到下次重置') - logger.attr('OpsiNextReset', next_reset) + logger.attr('大世界下次重置', next_reset) self.config.task_delay(target=next_reset) diff --git a/module/os_ash/ash.py b/module/os_ash/ash.py index b501dcb9c..28053b693 100644 --- a/module/os_ash/ash.py +++ b/module/os_ash/ash.py @@ -1,3 +1,15 @@ +"""大世界余烬信标模块。 + +提供碧蓝航线大世界(Operation Siren)中余烬(Ash/Ember)信标系统的自动化功能,包括: +- 信标数据收集进度的 OCR 读取与状态判断 +- 每日信标收集上限的检测 +- 信标战斗的特殊处理(战斗状态、战斗准备、经验信息跳过) +- 信标战斗完成的异常信号处理(AshBeaconFinished) +- 信标攻击任务的自动调度(收集进度 >= 100 时触发 OpsiAshBeacon 任务) + +余烬信标系统允许玩家收集信标数据来挑战 META 级别的敌人, +战斗结束后可获得 META 角色碎片等奖励。 +""" from datetime import timedelta from module.base.utils import image_left_strip @@ -27,7 +39,16 @@ class AshBeaconFinished(Exception): class AshCombat(Combat): - """余烬信标战斗处理器,处理战斗状态和战斗准备。""" + """余烬信标战斗处理器。 + + 继承 Combat,覆盖部分战斗处理逻辑以适配 META 战斗的特殊行为: + - 战斗状态页面的自定义点击处理(含掉落图像处理) + - 经验信息的跳过(META 战斗不掉落经验) + - 战斗准备页面的信标状态检测(完成 / 空信标 / 已在对决页面) + - 战斗执行中捕获 AshBeaconFinished 异常以正常退出 + + 当信标已完成或为空时,抛出 AshBeaconFinished 异常通知上层停止战斗循环。 + """ def handle_battle_status(self, drop=None): """ @@ -106,7 +127,19 @@ def combat(self, *args, expected_end=None, **kwargs): class OSAsh(UI, MapEventHandler): - """大世界余烬信标模块,负责信标收集状态检测和任务调度。""" + """大世界余烬信标模块。 + + 负责信标收集状态检测和信标攻击任务的自动调度。 + + 工作流程: + 1. 通过 OCR 读取信标收集进度(DigitCounter) + 2. 判断收集状态:可收集 / 未收集满 / 已达上限 / 被遮挡 + 3. 当收集进度 >= 100 且信标任务可调度时,触发 OpsiAshBeacon 任务 + 4. 检查信标任务的下次执行时间,距今超过 30 分钟才允许调用 + + Attributes: + _ash_fully_collected (bool): 信标数据是否已收集满(达到每日上限或持有上限)。 + """ _ash_fully_collected = False def ash_collect_status(self): diff --git a/module/os_ash/assets.py b/module/os_ash/assets.py index 0323b4685..eeabc9feb 100644 --- a/module/os_ash/assets.py +++ b/module/os_ash/assets.py @@ -1,3 +1,5 @@ +"""大世界余烬模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/os_ash/meta.py b/module/os_ash/meta.py index 780d6c3a5..2e69722e4 100644 --- a/module/os_ash/meta.py +++ b/module/os_ash/meta.py @@ -1,3 +1,9 @@ +"""余烬 META 战斗管理模块。 + +处理大世界余烬(Ash)信标系统的 META 战斗,包括信标等级 +OCR、伤害输出识别、META 战斗状态页面的检测与导航、奖励 +领取、以及自动搜索可用信标并发起挑战的完整战斗流程。 +""" import re from enum import Enum diff --git a/module/os_combat/assets.py b/module/os_combat/assets.py index 368fcf6bb..524ca10af 100644 --- a/module/os_combat/assets.py +++ b/module/os_combat/assets.py @@ -1,3 +1,5 @@ +"""大世界战斗模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/os_combat/combat.py b/module/os_combat/combat.py index ab74e5c6a..5d45cc29e 100644 --- a/module/os_combat/combat.py +++ b/module/os_combat/combat.py @@ -1,3 +1,9 @@ +"""大世界战斗处理器模块。 + +针对大世界(Operation Siren)场景定制战斗流程,继承标准战斗处理器 +和地图事件处理器。提供连续战斗检测(塞壬扫描装置场景)、S 评价 +延迟点击、大世界专用物品获取逻辑,以及战斗统计计时等功能。 +""" from module.combat.assets import * from module.combat.combat import Combat as Combat_ from module.logger import logger @@ -136,7 +142,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba auto (str): 自动战斗模式。 fleet_index (int): 舰队索引。 """ - logger.info('Combat preparation.') + logger.info('战斗准备。') self.device.stuck_record_clear() self.device.click_record_clear() skip_first_screenshot = True @@ -162,7 +168,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba # 结束 pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗界面', pause) break def _get_exp_info_sleep(self): @@ -330,13 +336,13 @@ def combat(self, *args, save_get_items=False, **kwargs): for count in range(3): self._clear_battle_status_s_timer() if count >= 2: - logger.warning('Too many continuous combat') + logger.warning('[大世界战斗] 连续战斗过多') try: super().combat(*args, save_get_items=save_get_items, **kwargs) break except ContinuousCombat: - logger.info('Continuous combat detected') + logger.info('[大世界战斗] 检测到连续战斗') continue finally: self._clear_battle_status_s_timer() @@ -358,9 +364,9 @@ def _handle_single_battle_status(self, status_button, status_letter, drop): """ if self.appear(status_button, interval=self.battle_status_click_interval): if status_letter == 'S': - logger.info(f'Battle Status {status_letter}') + logger.info(f'[大世界战斗] 战斗评价 {status_letter}') else: - logger.warning(f'Battle Status {status_letter}') + logger.warning(f'[大世界战斗] 战斗评价 {status_letter}') if drop: drop.handle_add(self) else: @@ -488,7 +494,7 @@ def auto_search_combat(self, drop=None): cl1_combat_timer = Timer(300, count=300) - logger.info('Auto search combat loading') + logger.info('[大世界战斗] 自动搜索战斗加载中') self.device.stuck_record_clear() self.device.click_record_clear() self.device.screenshot_interval_set('combat') @@ -504,12 +510,12 @@ def auto_search_combat(self, drop=None): break pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) break if self.is_in_map(): break - logger.info('Auto Search combat execute') + logger.info('[大世界战斗] 自动搜索战斗执行') self.submarine_call_reset() self.device.stuck_record_clear() self.device.click_record_clear() @@ -526,7 +532,7 @@ def auto_search_combat(self, drop=None): self.device.screenshot() if battle_timer_source == 'cl1' and cl1_combat_timer.reached(): - logger.warning('CL1 combat timeout (5 minutes limit reached)') + logger.warning('[大世界战斗] CL1 战斗超时(5 分钟限制)') raise GameBugError('CL1 combat timeout') if self.handle_submarine_call(submarine_mode): @@ -555,7 +561,7 @@ def auto_search_combat(self, drop=None): battle_status_s_timer.clear() continue - logger.info('Combat end.') + logger.info('战斗结束。') # 通过相同的指标源结束,避免 CL1 和 short-meow 样本意外共享存储键。 finish_battle_timer(self.config, battle_timer_source) diff --git a/module/os_handler/action_point.py b/module/os_handler/action_point.py index 0b6792992..dcd230e94 100644 --- a/module/os_handler/action_point.py +++ b/module/os_handler/action_point.py @@ -1,3 +1,9 @@ +"""大世界行动力管理模块。 + +处理大世界(Operation Siren)模式下的行动力(Action Point)管理。 +包含行动力数值的 OCR 识别、适应性属性读取、药剂(AP Box)库存解析, +以及自动购买或使用补给品的交互逻辑。 +""" # 此文件处理大世界(Operation Siren)模式下的行动力(Action Point, AP)管理。 # 包含行动力数值 OCR 识别、药剂(AP Box)库存解析以及自动购买或使用补给的交互逻辑。 from datetime import timedelta @@ -203,7 +209,7 @@ def action_point_update(self): oil = box[0] LogRes(self.config).Oil = oil - logger.info(f'Action points: {current}({total}), oil: {oil}') + logger.info(f'[大世界-行动点] 行动点: {current}({total}), 石油: {oil}') LogRes(self.config).ActionPoint = {'Value': current, 'Total': total} self.config.update() self._action_point_current = current @@ -225,7 +231,7 @@ def action_point_safe_get(self): if self.is_current_ap_visible(): break if timeout.reached(): - logger.warning('Get action points timeout, wait is_current_ap_visible timeout') + logger.warning('[大世界-行动点] 获取行动点超时') break # 处理行动力弹窗上方的强制地图事件 if self.handle_map_event(): @@ -241,7 +247,7 @@ def action_point_safe_get(self): self.device.screenshot() if timeout.reached(): - logger.warning('Get action points timeout') + logger.warning('[大世界-行动点] 获取行动点超时') break # 处理行动力弹窗上方的强制地图事件 if self.handle_map_event(): @@ -291,7 +297,7 @@ def action_point_get_cost(zone, pinned): elif pinned == 'STRONGHOLD': cost = 200 else: - logger.warning(f'Unable to get AP cost from zone={zone}, pinned={pinned}, assume it costs 40.') + logger.warning(f'[大世界-行动点] 无法获取行动点消耗, 区域={zone}, 固定={pinned},假设消耗40') cost = 40 if zone.is_port: @@ -314,7 +320,7 @@ def action_point_get_active_button(self): if color[2] > 160: return index - logger.warning('Unable to find an active action point box button') + logger.warning('[大世界-行动点] 无法找到活动的行动点箱子按钮') return 1 def action_point_set_button(self, index): @@ -334,7 +340,7 @@ def action_point_set_button(self, index): self.device.click(ACTION_POINT_GRID[index, 0]) self.device.sleep(0.3) else: - logger.warning('FSet action point button timeout') + logger.warning('[大世界-行动点] 设置行动点按钮超时') return False def action_point_get_buy_remain(self): @@ -358,7 +364,7 @@ def action_point_get_buy_remain(self): break else: - logger.warning('Get action points buy remain timeout') + logger.warning('[大世界-行动点] 获取行动点购买剩余超时') return current @@ -381,19 +387,19 @@ def action_point_buy(self, preserve=1000): buy_count = buy_max - current buy_limit = self.config.OpsiGeneral_BuyActionPointLimit if self._is_in_month_end_purchase_block_week(): - logger.info('Skip buying action points this week because it is the month-end block week') + logger.info('[大世界-行动点] 跳过本周购买行动点,因为是月末封锁周') return False if buy_count >= buy_limit: - logger.info('Reach the limit to buy action points this week') + logger.info('[大世界-行动点] 达到本周购买行动点上限') return False cost = ACTION_POINTS_BUY[current] oil = self._action_point_box[0] - logger.info(f'Buy action points will cost {cost}, current oil: {oil}, preserve: {preserve}') + logger.info(f'[大世界-行动点] 购买行动点将消耗 {cost}, 当前石油: {oil}, 保留: {preserve}') if oil >= cost + preserve: self.action_point_use() return True else: - logger.info('Not enough oil to buy') + logger.info('[大世界-行动点] 石油不足无法购买') return False def action_point_quit(self): @@ -452,15 +458,14 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che diff = get_server_next_update('00:00') - current_time() today_rest = int(diff.total_seconds() // 600) if self._action_point_current + today_rest >= 200: - logger.info('The sum of the current action points and the rest action points' - ' that can be obtained today exceeds 200, skip AP check') - logger.info(f'Current={self._action_point_current} Rest={today_rest}') + logger.info('[大世界处理-行动力] 当前行动力与今日可获得的剩余行动力之和超过 200,跳过行动力检查') + logger.info(f'[大世界-行动点] 当前={self._action_point_current} 今日剩余={today_rest}') keep_current_ap = False # 先检查行动力 if keep_current_ap: if self._action_point_total <= self.config.OS_ACTION_POINT_PRESERVE: - logger.info(f'Reach the limit of action points, preserve={self.config.OS_ACTION_POINT_PRESERVE}') + logger.info(f'[大世界-行动点] 达到行动点上限, 保留={self.config.OS_ACTION_POINT_PRESERVE}') self.action_point_quit() raise ActionPointLimit( current=self._action_point_current, @@ -471,7 +476,7 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che for _ in range(12): # 拥有足够的行动力 if self._action_point_current >= cost: - logger.info('Having enough action points') + logger.info('[大世界-行动点] 行动点充足') self.action_point_quit() return True @@ -486,7 +491,7 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che # 重新检查总行动力是否小于消耗 # 如果是,则跳过使用药剂 if self._action_point_total < cost: - logger.info('Not having enough action points') + logger.info('[大世界-行动点] 行动点不足') self.action_point_quit() raise ActionPointLimit( current=self._action_point_current, @@ -510,7 +515,7 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che self.action_point_use() continue else: - logger.info(f'Reach the limit of action points, preserve={self.config.OS_ACTION_POINT_PRESERVE}') + logger.info(f'[大世界-行动点] 达到行动点上限, 保留={self.config.OS_ACTION_POINT_PRESERVE}') self.action_point_quit() raise ActionPointLimit( current=self._action_point_current, @@ -518,7 +523,7 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che preserve=self.config.OS_ACTION_POINT_PRESERVE, ) else: - logger.info('No more action point boxes') + logger.info('[大世界-行动点] 没有更多行动点箱子') self.action_point_quit() raise ActionPointLimit( current=self._action_point_current, @@ -526,7 +531,7 @@ def handle_action_point(self, zone, pinned, cost=None, keep_current_ap=True, che cost=cost, ) - logger.warning('Failed to get action points after 12 trial') + logger.warning('[大世界-行动点] 尝试12次后仍无法获取行动点') return False def action_point_enter(self): @@ -594,9 +599,9 @@ def action_point_check(self, amount): enough = self._action_point_total > amount if enough: - logger.info(f'Having {amount} action points') + logger.info(f'[大世界-行动点] 拥有 {amount} 行动点') else: - logger.info(f'Not having {amount} action points') + logger.info(f'[大世界-行动点] 没有 {amount} 行动点') self.action_point_quit() for _ in self.loop(): diff --git a/module/os_handler/assets.py b/module/os_handler/assets.py index 0e64afcf1..4db54f494 100644 --- a/module/os_handler/assets.py +++ b/module/os_handler/assets.py @@ -1,3 +1,5 @@ +"""大世界处理器模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/os_handler/enemy_searching.py b/module/os_handler/enemy_searching.py index e8e07363d..bf819b144 100644 --- a/module/os_handler/enemy_searching.py +++ b/module/os_handler/enemy_searching.py @@ -1,3 +1,9 @@ +"""大世界敌人搜索处理器。 + +继承标准敌人搜索处理器,针对大世界地图场景进行适配。 +提供大世界地图内状态检测(含雾天地图识别)以及地图按钮 +滑入动画的等待逻辑,确保 UI 元素就绪后再进行后续操作。 +""" from module.handler.enemy_searching import EnemySearchingHandler as EnemySearchingHandler_ from module.logger import logger from module.os.assets import MAP_GOTO_GLOBE_FOG @@ -25,4 +31,4 @@ def wait_os_map_buttons(self): if self.appear_then_click(AUTO_SEARCH_REWARD, offset=(50, 50), interval=3): continue else: - logger.warning('wait_os_map_buttons timeout, assume waited') + logger.warning('[大世界处理-搜索] 大世界地图按钮等待超时,假设已等待完成') diff --git a/module/os_handler/map_event.py b/module/os_handler/map_event.py index 6a2c77157..7865ae6e4 100644 --- a/module/os_handler/map_event.py +++ b/module/os_handler/map_event.py @@ -1,3 +1,9 @@ +"""大世界地图事件处理器。 + +处理大世界地图探索过程中触发的各类事件,包括战斗奖励弹窗、 +故事跳过、舰队锁定开关、余烬信标弹窗、海域清除奖励以及 +自动搜索奖励等,是大世界战斗和探索流程的基础事件层。 +""" from typing import Optional from module.base.timer import Timer @@ -150,7 +156,7 @@ def handle_story_skip(self, drop=None): if self.appear(STORY_SKIP_3, offset=(20, 20), interval=0): if self._story_timeout.reached(): - logger.warning('Wait for story option timeout') + logger.warning('[大世界处理-事件] 等待剧情选项超时') self._story_timeout.reset() # 重启应用 @@ -315,7 +321,7 @@ def handle_os_map_fleet_lock(self, enable=None): # 舰队锁定取决于是否在地图上显示,而非地图状态 # 因为如果已在地图中,则没有地图状态 if not fleet_lock.appear(main=self): - logger.info('No fleet lock option.') + logger.info('[大世界处理-事件] 未找到舰队锁定选项') return False if enable is None: diff --git a/module/os_handler/map_order.py b/module/os_handler/map_order.py index df16b9a5a..65f1f7dee 100644 --- a/module/os_handler/map_order.py +++ b/module/os_handler/map_order.py @@ -1,3 +1,9 @@ +"""大世界地图指令处理器。 + +管理大世界地图中的指令操作(如进入海域、移动舰队等), +继承地图操作、行动力处理器和地图事件处理器。提供指令 +面板的进入/退出检测、海域颜色分析以判断海域状态等。 +""" import numpy as np from module.base.timer import Timer @@ -21,7 +27,7 @@ def order_enter(self): in: is_in_map out: is_in_map_order """ - logger.info('Order enter') + logger.info('进入指令') for _ in self.loop(): # End if self.is_in_map_order(): @@ -43,7 +49,7 @@ def order_quit(self): in: is_in_map_order out: is_in_map """ - logger.info('Order quit') + logger.info('退出指令') self.ui_click(ORDER_CHECK, appear_button=self.is_in_map_order, check_button=self.is_in_map, skip_first_screenshot=True) @@ -76,7 +82,7 @@ def order_execute(self, button): if self.is_in_map_order() and not self.appear(button): if missing_timer.reached(): - logger.info(f'Map order not available: {button}') + logger.info(f'[大世界处理-指令] 地图指令不可用: {button}') self.order_quit() return False else: @@ -150,7 +156,7 @@ def handle_map_cat_attack(self): if not self.map_cat_attack_timer.reached(): return False if np.sum(color_similarity_2d(self.image_crop(MAP_CAT_ATTACK, copy=False), (255, 231, 123)) > 221) > 100: - logger.info('Skip map cat attack') + logger.info('跳过地图猫攻击') self.device.click(CLICK_SAFE_AREA) self.map_cat_attack_timer.reset() return True diff --git a/module/os_handler/mission.py b/module/os_handler/mission.py index db740ec24..7a40c46b9 100644 --- a/module/os_handler/mission.py +++ b/module/os_handler/mission.py @@ -1,3 +1,9 @@ +"""大世界任务管理模块。 + +处理大世界(Operation Siren)的任务系统,包括任务提交、 +任务奖励领取、任务结算界面导航以及月度 Boss 任务的特殊处理。 +通过 OCR 和图像匹配检测任务状态,自动完成任务流程。 +""" from datetime import timedelta from module.base.timer import Timer @@ -81,7 +87,7 @@ def os_mission_enter(self, skip_siren_mission=False, skip_first_screenshot=True) in: MISSION_ENTER out: MISSION_CHECK """ - logger.info('进入大世界任务') + logger.info('[大世界处理-任务] 进入大世界任务') checkout_offset = (-20, -20, 20, 20) confirm_timer = Timer(2, count=6).start() for _ in self.loop(): @@ -91,12 +97,12 @@ def os_mission_enter(self, skip_siren_mission=False, skip_first_screenshot=True) and not self.match_template_color(MISSION_CHECKOUT, offset=checkout_offset, similarity=0.78): # 未找到任务,等待确认。任务可能加载较慢。 if confirm_timer.reached(): - logger.info('未找到大世界任务') + logger.info('[大世界处理-任务] 未找到大世界任务') break elif self.is_in_os_mission() \ and self.match_template_color(MISSION_CHECKOUT, offset=checkout_offset, similarity=0.78): # 找到至少一个任务 - logger.info('至少找到一个大世界任务') + logger.info('[大世界处理-任务] 至少找到一个大世界任务') break else: confirm_timer.reset() @@ -135,7 +141,7 @@ def os_mission_quit(self): """ 退出任务列表。 """ - logger.info('退出大世界任务') + logger.info('[大世界处理-任务] 退出大世界任务') for _ in self.loop(): # 结束 # 有时任务弹窗没有黑色模糊背景 @@ -164,29 +170,29 @@ def os_get_next_mission(self, skip_siren_mission=False): checkout_offset = self.os_mission_enter(skip_siren_mission=skip_siren_mission) checkout_offset = self._os_find_checkout_offset_skip_monthly_boss(checkout_offset) if checkout_offset is None: - logger.info('没有更多非月度Boss的大世界任务') + logger.info('[大世界处理-任务] 没有更多非月度Boss的大世界任务') self.os_mission_quit() return False if self.is_in_opsi_explore(): - logger.info('每月开荒+正在运行,仅接取任务并领取奖励') + logger.info('[大世界处理-任务] 每月开荒+正在运行,仅接取任务并领取奖励') self.os_mission_quit() return False - logger.info('接取大世界任务') + logger.info('[大世界处理-任务] 接取大世界任务') for _ in self.loop(): # 结束 if self.is_zone_pinned(): if self.get_zone_pinned_name() == 'ARCHIVE': - logger.info('Pinned at archive zone') + logger.info('[大世界处理-任务] 固定在档案区域') self.globe_enter(zone=self.name_to_zone(72)) return 'pinned_at_archive_zone' else: - logger.info('Pinned at mission zone') + logger.info('[大世界处理-任务] 固定在任务区域') self.globe_enter(zone=self.name_to_zone(72)) return 'pinned_at_mission_zone' if self.is_in_map() and self.info_bar_count(): - logger.info('Already at mission zone') + logger.info('[大世界处理-任务] 已在任务区域') return 'already_at_mission_zone' if self.appear_then_click(MISSION_CHECKOUT, offset=checkout_offset, interval=2, similarity=0.78): @@ -235,15 +241,14 @@ def os_mission_overview_accept(self, skip_siren_mission=False, skip_first_screen continue if self.info_bar_count(): if skip_siren_mission: - logger.info('Unable to accept missions, ' - 'because there are multiple Siren Research missions with the same name') + logger.info('[大世界处理-任务] 无法接受任务,存在多个同名塞壬研究任务') success = True else: - logger.info('Unable to accept missions, because reached the maximum number of missions') + logger.info('[大世界处理-任务] 无法接受任务,已达任务数量上限') success = False break if self.appear(MISSION_OVERVIEW_EMPTY, offset=(20, 20)): - logger.info('No more missions to accept') + logger.info('[大世界处理-任务] 无更多任务可接受') success = True break @@ -271,8 +276,8 @@ def is_in_opsi_explore(self): enable = self.config.is_task_enabled('OpsiExplore') next_run = self.config.cross_get(keys='OpsiExplore.Scheduler.NextRun', default=DEFAULT_TIME) next_reset = get_os_next_reset() - logger.attr('OpsiNextReset', next_reset) - logger.attr('OpsiExplore', (enable, next_run)) + logger.attr('大世界下次重置', next_reset) + logger.attr('每月开荒+', (enable, next_run)) # -12 小时以处理夏令时 # `next_run` 可能在夏令时之前计算,但现在是夏令时 # 2023-03-14 11:15:28.423 | INFO | [OpsiNextReset] 2023-04-01 03:00:00 diff --git a/module/os_handler/os_status.py b/module/os_handler/os_status.py index b54439151..8b5b7d02e 100644 --- a/module/os_handler/os_status.py +++ b/module/os_handler/os_status.py @@ -1,3 +1,9 @@ +"""大世界状态追踪模块。 + +管理大世界(Operation Siren)模式的状态信息,包括海域代币 +(黄币/紫币)的 OCR 数值追踪、任务类型识别、子任务冷却(CD) +状态的实时计算,以及相关日志资源的记录。 +""" # 此文件用于管理大世界(Operation Siren)模式下的状态信息。 # 负责海域代币(黄币/紫币)的数值追踪、任务类型识别以及子任务冷却(CD)状态的实时计算。 import threading @@ -120,13 +126,13 @@ def get_yellow_coins(self) -> int: current_value = OCR_SHOP_YELLOW_COINS.ocr(self.device.image) if timeout.reached(): - logger.warning('Get yellow coins timeout') + logger.warning('[大世界处理-状态] 获取黄币超时') break if current_value == 0: # OCR may get 0 when amount is not immediately loaded # Or when popups are obscuring the top bar - logger.info('Yellow coins is 0, assuming it is an ocr error or UI not loaded') + logger.info('[大世界处理-状态] 黄币为 0,可能是 OCR 错误或界面未加载') continue else: # 验证识别稳定性:连续两次识别相同才确认 @@ -143,14 +149,14 @@ def get_yellow_coins(self) -> int: # 如果最终仍未获取到有效数值,使用上次缓存的值(线程安全) with self._cache_lock: if yellow_coins == 0: - logger.info(f'Using cached yellow coins value: {self._last_yellow_coins}') + logger.info(f'[大世界处理-状态] 使用缓存的黄币值: {self._last_yellow_coins}') yellow_coins = self._last_yellow_coins # 缓存当前值用于降级 self._last_yellow_coins = yellow_coins LogRes(self.config).YellowCoin = yellow_coins - logger.info(f'Yellow coins: {yellow_coins}') + logger.info(f'[大世界处理-状态] 黄币: {yellow_coins}') return yellow_coins @@ -165,7 +171,7 @@ def get_purple_coins(self) -> int: def os_shop_get_coins(self): self._shop_yellow_coins = self.get_yellow_coins() self._shop_purple_coins = self.get_purple_coins() - logger.info(f'Yellow coins: {self._shop_yellow_coins}, purple coins: {self._shop_purple_coins}') + logger.info(f'[大世界处理-状态] 黄币: {self._shop_yellow_coins}, 紫币: {self._shop_purple_coins}') # 记录凭证快照到数据库(用于 WebUI 凭证变化曲线图) try: @@ -181,4 +187,4 @@ def os_shop_get_coins(self): # LogRes 已将值写入 config.modified,在此持久化 self.config.save() except Exception: - logger.exception('Failed to record coins snapshot') + logger.exception('[大世界处理-状态] 记录凭证快照失败') diff --git a/module/os_handler/port.py b/module/os_handler/port.py index f6e8bd52c..57440ba47 100644 --- a/module/os_handler/port.py +++ b/module/os_handler/port.py @@ -1,3 +1,14 @@ +"""大世界港口处理器模块。 + +提供碧蓝航线大世界(Operation Siren)港口相关的自动化操作,包括: +- 港口的进入与退出 +- 港口任务的接取(已弃用,自 2022.01.13 起任务仅在总览中显示) +- 港口商店的进入与退出(含意外进入情报界面的恢复逻辑) +- 港口船坞的舰船修复 + +港口是大世界中的核心节点,玩家可以在此补给、维修、接取任务和访问商店。 +本模块继承自 OSShop,与大世界商店模块共享商店交互能力。 +""" from module.base.timer import Timer from module.logger import logger from module.os_handler.assets import * @@ -12,6 +23,18 @@ class PortHandler(OSShop): + """大世界港口处理器。 + + 继承 OSShop,提供港口内的全部交互操作。 + + 港口功能: + - 进入 / 退出港口(从大世界地图进出) + - 任务接取(已弃用) + - 港口商店的进入与退出(含意外页面的恢复机制) + - 船坞舰船的批量修复 + + 注意:碧蓝航线和红轴港口的可用按钮不同,统一使用 PORT_GOTO_SUPPLY 作为检查点。 + """ def port_enter(self): """ 进入港口。 @@ -20,7 +43,7 @@ def port_enter(self): in: IN_MAP out: PORT_CHECK """ - logger.info('Port enter') + logger.info('进入港口') for _ in self.loop(): if self.appear(PORT_CHECK, offset=(20, 20)): break @@ -39,7 +62,7 @@ def port_quit(self, skip_first_screenshot=True): in: PORT_CHECK out: IN_MAP """ - logger.info('Port quit') + logger.info('退出港口') self.ui_back(appear_button=PORT_CHECK, check_button=self.is_in_map, skip_first_screenshot=skip_first_screenshot) # 底部按钮有显示动画 @@ -59,7 +82,7 @@ def port_mission_accept(self): out: PORT_CHECK """ if not self.appear(PORT_MISSION_RED_DOT): - logger.info('No available missions in this port') + logger.info('[大世界处理-港口] 此港口无可用任务') return True self.ui_click(PORT_GOTO_MISSION, appear_button=PORT_CHECK, check_button=PORT_MISSION_CHECK, @@ -78,7 +101,7 @@ def port_mission_accept(self): break if self.info_bar_count(): - logger.info('Unable to accept missions, because reached the maximum number of missions') + logger.info('[大世界处理-港口] 无法接受任务,已达任务数量上限') success = False break @@ -107,7 +130,7 @@ def port_shop_quit(self, skip_first_screenshot=True): in: PORT_SUPPLY_CHECK out: PORT_CHECK """ - logger.info('Port shop quit') + logger.info('退出港口商店') self.interval_clear([PORT_SUPPLY_CHECK, PORT_CHECK, ORDER_CHECK]) @@ -118,7 +141,7 @@ def port_shop_quit(self, skip_first_screenshot=True): while True: # 超时保护:同时满足时间超过 10 秒且 reached() 调用超过 30 次 if timeout.reached(): - logger.warning('port_shop_quit timeout, trying fallback ui_back') + logger.warning('[大世界处理-港口] 退出港口商店超时,尝试使用返回箭头') self.ui_back(appear_button=PORT_SUPPLY_CHECK, check_button=PORT_CHECK, skip_first_screenshot=True) break @@ -129,12 +152,12 @@ def port_shop_quit(self, skip_first_screenshot=True): # 成功返回到港口界面 if self.appear(PORT_CHECK, offset=(20, 20)): - logger.info('Arrive PORT_CHECK') + logger.info('[大世界处理-港口] 已返回港口界面') break # 意外进入情报界面(作战总览),用 order_quit 正确关闭 if self.appear(ORDER_CHECK, offset=(20, 20)): - logger.warning('Unexpected enter order page, executing order_quit') + logger.warning('[大世界处理-港口] 意外进入情报界面,执行 order_quit 退出') self.order_quit() order_quit_used = True self.interval_clear([PORT_SUPPLY_CHECK, PORT_CHECK, ORDER_CHECK]) @@ -143,7 +166,7 @@ def port_shop_quit(self, skip_first_screenshot=True): # 从情报界面退出后可能落在大地图,重新进入港口 if order_quit_used and self.is_in_map(): - logger.info('On map after order_quit, re-entering port') + logger.info('[大世界处理-港口] 从情报界面退出后在大地图,重新进入港口') self.port_enter() order_quit_used = False self.interval_reset(PORT_CHECK) diff --git a/module/os_handler/storage.py b/module/os_handler/storage.py index 586a31688..4cd09f658 100644 --- a/module/os_handler/storage.py +++ b/module/os_handler/storage.py @@ -1,3 +1,9 @@ +"""大世界存储管理模块。 + +管理大世界(Operation Siren)的仓库操作,包括舰船维修箱的 +使用与结果判断、仓库物品的滚动浏览以及存储界面的导航逻辑。 +提供维修结果枚举(成功/数量不足/超时)用于状态判断。 +""" from enum import Enum from module.base.timer import Timer @@ -38,7 +44,7 @@ def storage_enter(self): in: is_in_map, STORAGE_ENTER out: STORAGE_CHECK """ - logger.info('Storage enter') + logger.info('[大世界-仓库] 进入仓库') for _ in self.loop(): # End if self.is_in_storage(): @@ -60,7 +66,7 @@ def storage_quit(self): in: STORAGE_CHECK out: is_in_map """ - logger.info('Storage quit') + logger.info('[大世界-仓库] 退出仓库') self.ui_back(STORAGE_ENTER, offset=(200, 5), skip_first_screenshot=True) def _storage_item_use(self, button): @@ -84,12 +90,12 @@ def _storage_item_use(self, button): for _ in self.loop(): # Accidentally clicked on an item, having popups for its info if self.appear(GET_MISSION, offset=True, interval=2): - logger.info(f'_storage_item_use item info -> {GET_MISSION}') + logger.info(f'[大世界-仓库] 使用物品信息 -> {GET_MISSION}') self.device.click(GET_MISSION) self.interval_reset(STORAGE_CHECK) get_mission_counter += 1 if get_mission_counter >= 3: - logger.warning('Possibly stuck on energy storage device, redetecting logger items.') + logger.warning('[大世界-仓库] 可能卡在能量存储设备上,重新检测记录仪物品') break continue # Item rewards @@ -129,7 +135,7 @@ def storage_logger_use_all(self): in: STORAGE_CHECK out: STORAGE_CHECK, scroll to bottom """ - logger.hr('Storage logger use all') + logger.hr('使用所有记录仪') for _ in self.loop(): if SCROLL_STORAGE.appear(main=self): SCROLL_STORAGE.set_bottom(main=self, skip_first_screenshot=True) @@ -137,17 +143,17 @@ def storage_logger_use_all(self): image = rgb2gray(self.device.image) items = TEMPLATE_STORAGE_LOGGER.match_multi(image, similarity=0.5) items.extend(TEMPLATE_STORAGE_LOGGER_UNLOCK.match_multi(image, similarity=0.75)) - logger.attr('Storage_logger', len(items)) + logger.attr('记录仪数量', len(items)) if len(items): self._storage_item_use(items[0]) continue else: - logger.info('All loggers in storage have been used') + logger.info('[大世界-仓库] 仓库中所有记录仪已使用') break def logger_use(self): - logger.hr('Logger use') + logger.hr('使用记录仪') self.storage_enter() self.storage_logger_use_all() self.storage_quit() @@ -166,16 +172,16 @@ def storage_sample_use_all(self): for _ in self.loop(): image = rgb2gray(self.device.image) items = sample_type.match_multi(image, similarity=0.75) - logger.attr('Storage_sample', len(items)) + logger.attr('样本数量', len(items)) if len(items): self._storage_item_use(items[0]) else: break - logger.info('All samples in storage have been used') + logger.info('[大世界-仓库] 仓库中所有样本已使用') def tuning_sample_use(self, quit=True): - logger.hr('Turning sample use') + logger.hr('使用转化样本') self.storage_enter() self.storage_sample_use_all() if quit: @@ -208,11 +214,11 @@ def repair_ship_select(self, button, skip_first_screenshot=True): # End # blue background for area below hp bar means ship selected if self.image_color_count(image, color=(93, 148, 203), count=300): - logger.info('Storage Ship Selected') + logger.info('[大世界-仓库] 仓库舰船已选择') self.interval_clear(STORAGE_FLEET_CHOOSE) return True if timeout.reached(): - logger.warning('Wait storage ship select timeout') + logger.warning('[大世界-仓库] 等待仓库舰船选择超时') self.interval_clear(STORAGE_FLEET_CHOOSE) return False @@ -250,20 +256,20 @@ def repair_pack_use_confirm(self, button, skip_first_screenshot=True): # End - ship fixed (blue selection background disappeared) if self.appear(STORAGE_REPAIR_CONFIRM, offset=(20, 20)) and \ not self.image_color_count(image, color=(93, 148, 203), count=300): - logger.info('Ship Fixed') + logger.info('[大世界-仓库] 舰船已修复') return RepairResult.SUCCESS # End - ship already at full HP if self.handle_popup_cancel('STORAGE_REPAIR_FULL_CANCEL'): - logger.info('No need to fix this ship') + logger.info('[大世界-仓库] 无需修复此舰船') return RepairResult.SUCCESS # 处理"道具不足"弹窗:维修箱数量不足时游戏弹出此提示,需点击取消退出 # 截图显示弹窗标题为"信息 INFORMATION",内容为"道具不足",底部有取消按钮 if self.appear_then_click(POPUP_CANCEL, offset=(20, 20), interval=2): - logger.warning('[大世界] Repair pack insufficient (道具不足), skip this ship') + logger.warning('[大世界处理-存储] 维修包不足(道具不足),跳过此舰船') return RepairResult.PACK_INSUFFICIENT # 超时保护:防止未知弹窗导致死循环 if timeout.reached(): - logger.warning('repair_pack_use_confirm timeout, unknown popup or stuck state') + logger.warning('[大世界-仓库] 维修包使用确认超时,未知弹窗或卡住状态') return RepairResult.TIMEOUT if self.appear_then_click(STORAGE_REPAIR_CONFIRM, offset=(20, 20)): @@ -368,7 +374,7 @@ def storage_checkout_item(self, item, skip_obscure_hazard_2=False, skip_first_sc is_in_map, in previous zone if no more obscure/abyssal coordinates. STORAGE_FLEET_CHOOSE, for using repair packs. """ - logger.hr(f'Storage checkout item {item}') + logger.hr('仓库物品取出') if SCROLL_STORAGE.appear(main=self): if item == 'REPAIR_PACK': # repair packs always at the bottom page @@ -380,7 +386,7 @@ def storage_checkout_item(self, item, skip_obscure_hazard_2=False, skip_first_sc for _ in self.loop(): image = rgb2gray(self.device.image) items = self._storage_item_to_template(item).match_multi(image, similarity=0.75) - logger.attr(f'Storage_{item}', len(items)) + logger.attr(f'仓库_{item}', len(items)) if len(items): for button in items: @@ -391,7 +397,7 @@ def storage_checkout_item(self, item, skip_obscure_hazard_2=False, skip_first_sc self._storage_coordinate_checkout(button, types=(item,)) return True if confirm_timer.reached(): - logger.info(f'No more {item} items in storage') + logger.info(f'[大世界-仓库] 仓库中没有更多 {item} 物品') self.storage_quit() return False @@ -411,7 +417,7 @@ def storage_get_next_item(self, item, use_logger=True, skip_obscure_hazard_2=Fal is_in_map, in previous zone if no more obscure/abyssal coordinates. STORAGE_FLEET_CHOOSE, for using repair packs. """ - logger.hr('OS get next obscure') + logger.hr('[大世界处理-存储] 获取下一个隐秘海域') self.storage_enter() if use_logger: self.storage_logger_use_all() diff --git a/module/os_handler/strategic.py b/module/os_handler/strategic.py index 6f455525a..25d991e53 100644 --- a/module/os_handler/strategic.py +++ b/module/os_handler/strategic.py @@ -1,3 +1,9 @@ +"""大世界战略搜索处理器。 + +管理大世界战略搜索(Strategic Search)功能的交互流程。 +提供战略搜索面板的进入、标签页切换(已净化/未净化)、 +搜索选项勾选、确认弹窗处理以及滚动条控制等操作。 +""" from module.base.utils import get_color from module.logger import logger from module.os_handler.assets import * @@ -9,7 +15,7 @@ class StrategicSearchHandler(MapEventHandler): def strategy_search_enter(self): - logger.info('Strategic search enter') + logger.info('[大世界-策略] 进入策略搜索') self.interval_clear(STRATEGIC_SEARCH_MAP_OPTION_OFF) for _ in self.loop(): # End @@ -25,7 +31,7 @@ def strategy_search_enter(self): continue def strategic_search_set_tab(self): - logger.info('Strategic search set tab') + logger.info('[大世界-策略] 设置策略搜索标签') for _ in self.loop(): if get_color(self.device.image, STRATEGIC_SEARCH_TAB_SECURED.area)[2] <= 150: self.device.click(STRATEGIC_SEARCH_TAB_SECURED) @@ -42,9 +48,9 @@ def _strategy_search_scroll_appear(self): if STRATEGIC_SEARCH_SCROLL.appear(main=self): return True else: - logger.warning('STRATEGIC_SEARCH_SCROLL disappeared') + logger.warning('[大世界-策略] 策略搜索滚动条消失') else: - logger.warning('STRATEGIC_SEARCH_SCROLL disappeared confirm') + logger.warning('[大世界-策略] 策略搜索滚动条消失确认') return False def _strategy_option_selected(self, button): @@ -58,19 +64,19 @@ def strategic_search_set_option(self): Returns: If success. False if strategic settings closed for unknown reason. """ - logger.info('Strategic search set option') + logger.info('[大世界-策略] 设置策略搜索选项') for _ in self.loop(): if self._strategy_option_selected(STRATEGIC_SEARCH_ZONEMODE_REPEAT) \ and self._strategy_option_selected(STRATEGIC_SEARCH_MERCHANT_STOP): - logger.attr('zone_mode', 'repeat') - logger.attr('encounter_merchant', 'stop') + logger.attr('区域模式', '重复') + logger.attr('遭遇商人', '停止') break if self._strategy_option_selected(STRATEGIC_SEARCH_ZONEMODE_RANDOM): - logger.attr('zone_mode', 'random') + logger.attr('区域模式', '随机') self.device.click(STRATEGIC_SEARCH_ZONEMODE_REPEAT) continue if self._strategy_option_selected(STRATEGIC_SEARCH_MERCHANT_CONTINUE): - logger.attr('encounter_merchant', 'continue') + logger.attr('遭遇商人', '继续') self.device.click(STRATEGIC_SEARCH_MERCHANT_STOP) continue @@ -85,10 +91,10 @@ def strategic_search_set_option(self): STRATEGIC_SEARCH_DEVICE_CONTINUE.load_offset(STRATEGIC_SEARCH_DEVICE_CHECK) if self._strategy_option_selected(STRATEGIC_SEARCH_DEVICE_STOP): - logger.attr('encounter_device', 'stop') + logger.attr('遭遇装置', '停止') break if self._strategy_option_selected(STRATEGIC_SEARCH_DEVICE_CONTINUE): - logger.attr('encounter_device', 'continue') + logger.attr('遭遇装置', '继续') self.device.click(STRATEGIC_SEARCH_DEVICE_STOP) continue @@ -104,17 +110,17 @@ def strategic_search_set_option(self): STRATEGIC_SEARCH_SUBMIT_ON.load_offset(STRATEGIC_SEARCH_SUBMIT_CHECK) if self._strategy_option_selected(STRATEGIC_SEARCH_SUBMIT_ON): - logger.attr('auto_submit', 'on') + logger.attr('自动提交', '开启') break if self._strategy_option_selected(STRATEGIC_SEARCH_SUBMIT_OFF): - logger.attr('auto_submit', 'off') + logger.attr('自动提交', '关闭') self.device.click(STRATEGIC_SEARCH_SUBMIT_ON) continue return True def strategic_search_confirm(self): - logger.info('Strategic search confirm') + logger.info('[大世界-策略] 策略搜索确认') for _ in self.loop(): if self.appear(STRATEGIC_SEARCH_POPUP_CHECK, offset=(20, 20)) \ and self.handle_popup_confirm(offset=(30, 30), name='STRATEGIC_SEARCH'): @@ -134,7 +140,7 @@ def strategic_search_start(self, skip_first_screenshot=True): in: IN_MAP out: IN_MAP, with strategic search running """ - logger.hr('Strategic search start') + logger.hr('策略搜索开始') for _ in range(3): self.strategy_search_enter() self.strategic_search_set_tab() @@ -144,5 +150,5 @@ def strategic_search_start(self, skip_first_screenshot=True): self.strategic_search_confirm() return True - logger.warning('Failed to start strategic search') + logger.warning('[大世界-策略] 策略搜索启动失败') return False diff --git a/module/os_handler/target.py b/module/os_handler/target.py index 0e6c2b941..2f63e3a8f 100644 --- a/module/os_handler/target.py +++ b/module/os_handler/target.py @@ -1,3 +1,9 @@ +"""大世界目标系统处理器。 + +管理大世界目标面板的交互,包括目标筛选(全部/未完成)、 +目标浏览(上一个/下一个)、奖励领取(单个/全部)以及 +目标区域 ID 的 OCR 识别,用于判断海域的目标完成状态。 +""" from module.base.timer import Timer from module.base.button import * from module.combat.combat import Combat @@ -129,7 +135,7 @@ def receive_reward(self): Returns: bool: 是否领取成功。 """ - logger.hr('OS Achievement Reward Receive', level=2) + logger.hr('大世界成就奖励领取', level=2) TARGET_SWITCH.set('all', main=self) received = False if self.appear(TARGET_RECEIVE_ALL): @@ -137,9 +143,9 @@ def receive_reward(self): elif self.find_unreceived_zone(): received = self._receive_reward_single() if received: - logger.info(f'Opsi achievement reward collection received') + logger.info(f'大世界成就奖励已领取') else: - logger.info(f'No Opsi achievement reward available') + logger.info(f'无大世界成就奖励可用') return received def _is_finished(self, area): @@ -163,7 +169,7 @@ def scan_current_zone(self): """ zone_id = ZONE_ID.ocr(self.device.image) finished = [self._is_finished(button.area) for button in self._star_grid().buttons] - logger.info(f'Zone {zone_id} target progress: {str(finished)}') + logger.info(f'[大世界处理-成就] 海域 {zone_id} 目标进度: {str(finished)}') return zone_id, finished def find_unfinished_safe_star_zone(self, skip_first_screenshot=True): @@ -191,13 +197,13 @@ def find_unfinished_safe_star_zone(self, skip_first_screenshot=True): for index in range(1, 5): if not finished[index]: if self.is_file(zone_id, index): - logger.info(f'No. {index+1} of Zone {zone_id} is a file target, skipped.') + logger.info(f'[大世界处理-成就] 区域 {zone_id} 第 {index+1} 项是文件目标,跳过') continue elif self.is_safe(zone_id, index): - logger.info(f'No. {index+1} of Zone {zone_id} is safe for MeowfficerFarming.') + logger.info(f'[大世界处理-成就] 区域 {zone_id} 第 {index+1} 项对指挥喵安全') return zone_id else: - logger.info(f"No. {index+1} of Zone {zone_id} can only be done in danger zone, skipped.") + logger.info(f"[大世界处理-成就] 区域 {zone_id} 第 {index+1} 项只能在危险区域完成,跳过") continue if self.appear(TARGET_NEXT_ZONE): self.device.click(TARGET_NEXT_ZONE) @@ -206,7 +212,7 @@ def find_unfinished_safe_star_zone(self, skip_first_screenshot=True): info_timer.reset() continue else: - logger.info(f'All remaining stars can only be finished in danger zone.') + logger.info(f'所有剩余星星只能在危险区域完成。') return 0 def run(self): @@ -214,11 +220,11 @@ def run(self): zone = self.find_unfinished_safe_star_zone() with self.config.multi_set(): if zone == 0: - logger.info('Disable Safe Target Farming') + logger.info('禁用安全目标刷取') self.config.OpsiTarget_TargetZone = 0 self.config.OpsiTarget_TargetFarming = False else: - logger.info(f'Successfully found safe target zone, zone_id={zone}') + logger.info(f'成功找到安全目标区域, zone_id={zone}') self.config.OpsiTarget_TargetZone = zone TARGET_SWITCH.set('all', main=self) diff --git a/module/os_handler/target_data.py b/module/os_handler/target_data.py index 326808117..acec772ea 100644 --- a/module/os_handler/target_data.py +++ b/module/os_handler/target_data.py @@ -1,3 +1,9 @@ +"""大世界目标静态数据。 + +由 dev_tools/os_target_extract.py 自动生成,存储每个海域的目标 +完成状态数据。字典键为海域编号,值为布尔值或元组列表,分别表示 +各目标槽的安全状态和文件引用(章节编号, 目标索引)。 +""" # This file was automatically generated by dev_tools/os_target_extract.py. # Don't modify it manually. diff --git a/module/os_shop/akashi_shop.py b/module/os_shop/akashi_shop.py index 69ed5d63b..25e12f000 100644 --- a/module/os_shop/akashi_shop.py +++ b/module/os_shop/akashi_shop.py @@ -1,3 +1,9 @@ +"""大世界明石商店模块。 + +管理大世界(Operation Siren)明石(Akashi)商店的购买逻辑。 +提供各服务器(CN/EN/JP/TW)的物品网格配置差异、商品识别、 +购买决策以及购买流程的状态循环控制。 +""" from typing import List from module.base.button import Button, ButtonGrid from module.base.decorator import Config, cached_property diff --git a/module/os_shop/assets.py b/module/os_shop/assets.py index cf55586e6..d9fe08375 100644 --- a/module/os_shop/assets.py +++ b/module/os_shop/assets.py @@ -1,3 +1,5 @@ +"""大世界商店模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/os_shop/item.py b/module/os_shop/item.py index 12610015d..77c04a70e 100644 --- a/module/os_shop/item.py +++ b/module/os_shop/item.py @@ -1,3 +1,9 @@ +"""大世界商店物品数据模块。 + +定义大世界商店的物品数据结构,包括价格 OCR 识别器(修正常见 +OCR 错误)、计数器 OCR 识别器以及商品价格区域的识别逻辑, +为商店购买决策提供准确的价格和数量信息。 +""" from typing import List import module.config.server as server from module.logger import logger diff --git a/module/os_shop/port_shop.py b/module/os_shop/port_shop.py index 862309310..f77604f07 100644 --- a/module/os_shop/port_shop.py +++ b/module/os_shop/port_shop.py @@ -1,3 +1,9 @@ +"""大世界港口商店模块。 + +管理大世界(Operation Siren)港口(Port)商店的物品浏览和购买。 +提供货币图标模板加载、商品网格定位、物品查找以及全量扫描功能, +支持在港口商店中自动购买指定商品。 +""" from typing import List from module.base.button import ButtonGrid @@ -44,7 +50,7 @@ def _get_os_shop_cost(self) -> list: """ image = self.image_crop((360, 320, 410, 700)) result = sum([template.match_multi(image) for template in self.TEMPLATES], []) - logger.attr('Costs', f'{result}') + logger.attr('货币图标位置', f'{result}') return Points([(0., m.area[1]) for m in result]).group(threshold=5) @cached_property @@ -101,12 +107,12 @@ def os_shop_get_items(self, shop_index=False, scroll_pos=False) -> List[Item]: if len(shop_items): min_row = self.os_shop_items.grids[0, 0].area[1] row = [str(item) for item in shop_items if item.button[1] == min_row] - logger.info(f'大世界商店+第 1 行: {row}') + logger.info(f'[大世界商店-港口] 大世界商店+第 1 行: {row}') row = [str(item) for item in shop_items if item.button[1] != min_row] - logger.info(f'大世界商店+第 2 行: {row}') + logger.info(f'[大世界商店-港口] 大世界商店+第 2 行: {row}') return shop_items else: - logger.info('未找到大世界商店+物品') + logger.info('[大世界商店-港口] 未找到大世界商店+物品') return [] @@ -125,7 +131,7 @@ def os_shop_get_items_to_buy(self, name, price) -> Item: items = self.os_shop_get_items() for _ in range(2): if not len(items) or any(not item.is_known_item() for item in items): - logger.warning('大世界商店+为空或物品为空,正在确认') + logger.warning('[大世界商店-港口] 大世界商店+为空或物品为空,正在确认') self.device.sleep((0.3, 0.5)) self.device.screenshot() items = self.os_shop_get_items() @@ -163,12 +169,12 @@ def scan_all(self) -> List[Item]: for _ in range(3): _items = self.os_shop_get_items(i, cur_pos) if not len(_items) or any(not item.is_known_item() for item in _items): - logger.warning('大世界商店+为空或物品为空,正在确认') + logger.warning('[大世界商店-港口] 大世界商店+为空或物品为空,正在确认') self.device.sleep((0.3, 0.5)) self.device.screenshot() continue else: - logger.info(f'在商店 {i + 1} 的位置 {cur_pos:.2f} 找到 {len(_items)} 个物品') + logger.info(f'[大世界商店-港口] 在商店 {i + 1} 的位置 {cur_pos:.2f} 找到 {len(_items)} 个物品') break # 始终添加物品,即使最后的物品列表包含未知物品 # 这样可以扫描到所有已知物品 @@ -179,7 +185,7 @@ def scan_all(self) -> List[Item]: items.append(item) if OS_SHOP_SCROLL.at_bottom(main=self): - logger.info('大世界商店+已滚动到底部,停止扫描') + logger.info('[大世界商店-港口] 大世界商店+已滚动到底部,停止扫描') break else: OS_SHOP_SCROLL.next_page(main=self, page=0.5, skip_first_screenshot=False) diff --git a/module/os_shop/shop.py b/module/os_shop/shop.py index 5ddb28f02..1bc620ec7 100644 --- a/module/os_shop/shop.py +++ b/module/os_shop/shop.py @@ -1,7 +1,16 @@ -"""大世界商店+购买执行类。 - -整合港口商店与海域内明石商店的交互、购买确认逻辑, -并记录行动力购买等大世界关键资源的变动明细。 +"""大世界商店模块。 + +提供碧蓝航线大世界(Operation Siren)商店的自动化购买功能,包括: +- 港口商店(Port Shop)的扫描、过滤与批量购买 +- 海域内明石商店(Akashi Shop)的交互与购买 +- 购买数量的智能计算(基于货币余额和库存上限) +- 黄币 / 紫币的余额管理与保留量控制 +- 购买确认弹窗和数量选择器的处理 +- 大世界重置周期下的货币策略调整 +- 侵蚀 1 练级模式下的明石行动力购买记录 + +本模块整合了 PortShop 和 AkashiShop 两个子模块的功能, +通过统一的购买执行接口处理大世界中的所有商店交互。 """ from module.base.decorator import cached_property from module.base.timer import Timer @@ -18,9 +27,22 @@ class OSShop(PortShop, AkashiShop): - """大世界商店+购买执行器。 - - 继承港口商店和明石商店的功能,提供统一的购买执行接口。 + """大世界商店购买执行器。 + + 继承港口商店(PortShop)和明石商店(AkashiShop)的功能, + 提供统一的购买执行接口和货币管理策略。 + + 主要功能: + - 单个物品购买执行(含确认弹窗、数量选择、重试机制) + - 批量物品购买循环 + - 购买数量的智能计算(基于货币余额、库存、保留量) + - 黄币 / 紫币的可用余额计算(考虑大世界重置周期) + - 港口商店的完整购买流程(扫描 -> 过滤 -> 购买) + - 明石商店的购买交互(进入海域商店 -> 购买 -> 返回地图) + + Attributes: + _shop_yellow_coins (int): 当前黄币余额(由 os_shop_get_coins 设置)。 + _shop_purple_coins (int): 当前紫币余额(由 os_shop_get_coins 设置)。 """ def os_shop_buy_execute(self, button, skip_first_screenshot=True) -> bool: @@ -73,7 +95,7 @@ def os_shop_buy_execute(self, button, skip_first_screenshot=True) -> bool: amount_finish = self.shop_buy_amount_handler(button) set_amount_retry += 1 if not amount_finish and set_amount_retry > 3: - logger.warning(f'物品 {button.name} 无法识别购买数量') + logger.warning(f'[大世界商店] 物品 {button.name} 无法识别购买数量') self.close_shop_buy_confirm_amount(skip_first_screenshot) break continue @@ -88,7 +110,7 @@ def os_shop_buy_execute(self, button, skip_first_screenshot=True) -> bool: if not success and self.appear(PORT_SUPPLY_CHECK, offset=(20, 20), interval=5): buy_retry += 1 if buy_retry > buy_retry_limit: - logger.warning(f'物品 {button.name} 达到购买重试上限,可能货币不足') + logger.warning(f'[大世界商店] 物品 {button.name} 达到购买重试上限,可能货币不足') break amount_finish = False self.device.click(button) @@ -118,13 +140,13 @@ def os_shop_buy(self, select_func) -> int: for _ in range(12): button = select_func() if button is None: - logger.info('大世界商店+购买完成') + logger.info('[大世界商店] 大世界商店+购买完成') return count else: self.os_shop_buy_execute(button) try: if not getattr(self, 'is_running_cl1_leveling', False): - logger.debug('侵蚀1练级未运行,跳过明石行动力购买记录') + logger.debug('[大世界商店] 侵蚀1练级未运行,跳过明石行动力购买记录') else: name = str(getattr(button, 'name', '') or '') name_l = name.lower() @@ -146,16 +168,16 @@ def os_shop_buy(self, select_func) -> int: count=int(amount), source='akashi' ) - logger.info('已记录明石行动力购买数据到数据库') + logger.info('[大世界商店] 已记录明石行动力购买数据到数据库') except Exception: - logger.exception('保存明石行动力购买数据失败') + logger.exception('[大世界商店] 保存明石行动力购买数据失败') except Exception: - logger.exception('记录明石购买数据时发生异常') + logger.exception('[大世界商店] 记录明石购买数据时发生异常') count += 1 continue - logger.warning('待购买物品过多,停止购买') + logger.warning('[大世界商店] 待购买物品过多,停止购买') return count def close_shop_buy_confirm_amount(self, skip_first_screenshot=True): @@ -210,7 +232,7 @@ def shop_buy_amount_handler(self, item, skip_first_screenshot=True): limit = OCR_SHOP_AMOUNT.ocr(self.device.image) if limit == 0: - logger.warning('OCR_SHOP_AMOUNT 识别为 0,正在重试') + logger.warning('[大世界商店] OCR_SHOP_AMOUNT 识别为 0,正在重试') self.close_shop_buy_confirm_amount() return False @@ -267,7 +289,7 @@ def shop_buy_amount_handler(self, item, skip_first_screenshot=True): # AMOUNT_MAX点击后数量仍为1,说明按钮可能被游戏禁用(如商品只能逐个购买) amount_max_stall += 1 if amount_max_stall >= amount_max_stall_limit: - logger.info(f'AMOUNT_MAX 点击 {amount_max_stall} 次后数量仍为 {current_amount},改用 AMOUNT_PLUS') + logger.info(f'[大世界商店] AMOUNT_MAX 点击 {amount_max_stall} 次后数量仍为 {current_amount},改用 AMOUNT_PLUS') break # 仅在已点击AMOUNT_MAX且数量成功增加时,才能读取游戏端实际允许的最大数量 diff --git a/module/os_shop/ui.py b/module/os_shop/ui.py index 552d05651..262f6f648 100644 --- a/module/os_shop/ui.py +++ b/module/os_shop/ui.py @@ -1,3 +1,9 @@ +"""大世界商店 UI 操作模块。 + +提供大世界商店界面的通用 UI 操作,包括商店页面加载检测、 +侧边栏导航(明石商店/港口商店)、自适应滚动条控制、 +安全区域点击防护以及死循环检测等基础交互功能。 +""" from typing import Tuple from module.base.button import ButtonGrid from module.base.decorator import cached_property diff --git a/module/os_simulator/constants.py b/module/os_simulator/constants.py index 3f5738ddb..d01b727f2 100644 --- a/module/os_simulator/constants.py +++ b/module/os_simulator/constants.py @@ -1,3 +1,9 @@ +"""大世界模拟器常量定义。 + +定义大世界模拟器使用的所有常量,包括状态数组索引(行动力、 +代币、状态、已用时间等)、明石商店奖励矩阵、行动力恢复速率、 +各海域行动力消耗以及状态枚举值(净化/明石/崩溃/完成)。 +""" import numpy as np AP = 0 diff --git a/module/os_simulator/logger.py b/module/os_simulator/logger.py index 3bfce3254..dbc1c65d0 100644 --- a/module/os_simulator/logger.py +++ b/module/os_simulator/logger.py @@ -1,3 +1,9 @@ +"""大世界模拟器日志模块。 + +为大世界模拟器提供独立的日志系统,包括 tqdm 输出重定向器 +(用于非控制台环境)和模拟器专用日志器(基于文件轮转), +确保模拟运行过程可追溯且不影响主程序日志。 +""" import logging import os from datetime import datetime diff --git a/module/os_simulator/plotter.py b/module/os_simulator/plotter.py index ecafb4745..9d4fddb37 100644 --- a/module/os_simulator/plotter.py +++ b/module/os_simulator/plotter.py @@ -1,3 +1,9 @@ +"""大世界模拟器可视化模块。 + +提供大世界模拟器的图表绘制功能,包括单样本轨迹图、 +多轮模拟的行动力/代币/完成海域数分布直方图以及 +综合报告图,使用 matplotlib 生成并保存为 PNG 图像。 +""" import os import numpy as np import matplotlib diff --git a/module/os_simulator/simulator.py b/module/os_simulator/simulator.py index b67bc7314..b491e3963 100644 --- a/module/os_simulator/simulator.py +++ b/module/os_simulator/simulator.py @@ -1,3 +1,9 @@ +"""大世界模拟器核心模块。 + +通过蒙特卡洛模拟评估大世界(Operation Siren)行动力的最优使用策略。 +使用 Numba JIT 加速采样计算,模拟明石商店抽奖、海域净化、 +行动力消耗等随机过程,输出统计分布以辅助策略决策。 +""" import numpy as np import numba as nb import threading diff --git a/module/private_quarters/assets.py b/module/private_quarters/assets.py index 44ff56bd4..d73945c00 100644 --- a/module/private_quarters/assets.py +++ b/module/private_quarters/assets.py @@ -1,3 +1,5 @@ +"""私人休息室模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/private_quarters/clerk.py b/module/private_quarters/clerk.py index d2e8bf7c9..06cb95552 100644 --- a/module/private_quarters/clerk.py +++ b/module/private_quarters/clerk.py @@ -1,3 +1,12 @@ +""" +私人休息室商店店员逻辑。 + +封装私人宿舍商店的购买流程,继承通用 ShopClerk 和 PQShopUI, +提供商品购买执行、间隔计时器清除等操作。 +支持最大数量购买和确认弹窗处理。 + +Pages: in: PRIVATE_QUARTERS_SHOP +""" from module.base.timer import Timer from module.logger import logger from module.private_quarters.assets import * @@ -85,17 +94,17 @@ def shop_buy(self): out: 私人宿舍商店 """ for _ in range(12): - logger.hr('Shop buy', level=2) + logger.hr('商店购买', level=2) # 先获取商品列表,再读取货币以获得更准确的 OCR 结果 items = self.shop_get_items() self.shop_currency() if self._currency <= 0: - logger.warning(f'Current funds: {self._currency}, stopped') + logger.warning(f'[私人休息室-店员] 当前资金: {self._currency},停止购买') return False item = self.shop_get_item_to_buy(items) if item is None: - logger.info('Shop buy finished') + logger.info('[私人休息室-店员] 商店购买完成') return True else: self.shop_buy_execute(item) @@ -106,5 +115,5 @@ def shop_buy(self): continue - logger.warning('Too many items to buy, stopped') + logger.warning('[私人休息室-店员] 购买物品过多,停止') return True diff --git a/module/private_quarters/interact.py b/module/private_quarters/interact.py index ceda5851e..1a9b6505d 100644 --- a/module/private_quarters/interact.py +++ b/module/private_quarters/interact.py @@ -1,3 +1,12 @@ +""" +私人休息室舰船互动逻辑。 + +管理私人宿舍中与舰船角色的互动流程,包括目标房间导航、 +对话事件处理、互动按钮点击和完成状态检测。 +支持多目标舰船(安克雷奇、能代、天狼星等)的互动编排。 + +页面s: in: PRIVATE_QUARTERS +""" from module.base.timer import Timer from module.base.utils import random_rectangle_vector from module.handler.assets import POPUP_CANCEL @@ -10,7 +19,7 @@ class PQInteract(UI): # Key: str, target ship name # Value: list[Button], button instances - # (Room_Entrance, Page_Locale) + # (房间_Entrance, 页面_Locale) available_targets = { 'anchorage': (PRIVATE_QUARTERS_SHIP_ANCHORAGE, PRIVATE_QUARTERS_PAGE_LOCALE_BEACH), 'noshiro': (PRIVATE_QUARTERS_SHIP_NOSHIRO, PRIVATE_QUARTERS_PAGE_LOCALE_BEACH), @@ -105,17 +114,14 @@ def _pq_goto_room_seek(self, target_ship): """ target_title = target_ship.title().replace('_', ' ') if target_ship not in self.available_targets: - logger.error(f'Unsupported target ship: {target_title}, ' - 'cannot continue subtask') + logger.error(f'[私人休息室-互动] 不支持的目标舰娘: {target_title},无法继续子任务') return False elif len(self.available_targets[target_ship]) < 2: - logger.error('Missing tuple info page locale for ' - f'target ship: {target_title}, cannot ' - 'continue subtask') + logger.error(f'[私人休息室-互动] 目标舰娘 {target_title} 缺少页面位置信息,无法继续子任务') return False page_btn = self.available_targets[target_ship][1] - logger.hr(f'Seek {target_title}\'s Page', level=2) + logger.hr(f'[私人休息室-互动] 寻找 {target_title} 页面', level=2) # Depending on current page position # Search left then right or reverse order @@ -136,7 +142,7 @@ def _pq_goto_room_seek(self, target_ship): # End, success if self.appear(page_btn, offset=(20, 20)): - logger.info(f'Reached {target_title}\'s page') + logger.info(f'[私人休息室-互动] 已到达 {target_title} 页面') return True # Enable interval delay to confirm page after click @@ -149,7 +155,7 @@ def _pq_goto_room_seek(self, target_ship): if settle_timer.reached(): break - logger.warning(f'{target_title}\'s page cannot be found') + logger.warning(f'[私人休息室-互动] 未找到 {target_title} 页面') return False def _pq_goto_room_check(self): @@ -177,13 +183,10 @@ def _pq_goto_room_enter(self, target_ship): # prompt appears after click target_title = target_ship.title().replace('_', ' ') if target_ship not in self.available_targets: - logger.error(f'Unsupported target ship: {target_title}, ' - 'cannot continue subtask') + logger.error(f'[私人休息室-互动] 不支持的目标舰娘: {target_title},无法继续子任务') return False elif len(self.available_targets[target_ship]) < 1: - logger.error('Missing tuple info room entrance for ' - f'target ship: {target_title}, cannot ' - 'continue subtask') + logger.error(f'[私人休息室-互动] 目标舰娘 {target_title} 缺少房间入口信息,无法继续子任务') return False target_btn = self.available_targets[target_ship][0] @@ -197,7 +200,7 @@ def _pq_goto_room_enter(self, target_ship): # If was download asset popup # Terminate the run if self.handle_popup_cancel('PRIVATE_QUARTERS_DOWNLOAD_ASSET', offset=(20, 20)): - logger.error(f'Cannot enter {target_title}\'s room, please download the necessary assets first') + logger.error(f'[私人休息室-互动] 无法进入 {target_title} 的房间,请先下载所需资源') return False # Fully enter into target's room @@ -208,7 +211,7 @@ def _pq_goto_room_enter(self, target_ship): # Terminate the run if self.appear(PRIVATE_QUARTERS_ROOM_TARGET_INTIMACY_MAX, offset=(20, 20)): logger.warning( - f'{target_title}\'s intimacy is maxed, configure to new target or turn off subtask altogether') + f'[私人休息室-互动] {target_title} 好感度已满,请更换目标或关闭子任务') return False return True @@ -242,7 +245,7 @@ def pq_interact(self): Parameters identified as stable and server transparent """ # Click target ship girl for 1st stage sequence - logger.hr(f'Interact Start', level=2) + logger.hr(f'[私人休息室-互动] 互动开始', level=2) interact_offset = (-10, 0, 0, 65) click_timer = Timer(1.5, count=3).start() skip_first_screenshot = True @@ -262,7 +265,7 @@ def pq_interact(self): # Repeat 2nd and 3rd stage sequence 3 times for i in range(1, 4): - logger.hr(f'Interact Loop {i}/3', level=3) + logger.hr(f'[私人休息室-互动] 互动循环 {i}/3', level=3) self.interval_clear([PRIVATE_QUARTERS_INTERACT_CHECK, PRIVATE_QUARTERS_INTERACT]) skip_first_screenshot = True @@ -294,7 +297,7 @@ def pq_interact(self): self.device.click(PRIVATE_QUARTERS_ROOM_BACK) continue - logger.hr(f'Interact End', level=2) + logger.hr(f'[私人休息室-互动] 互动结束', level=2) self._pq_goto_room_exit() def pq_goto_room(self, target_ship, retry=3): @@ -312,7 +315,7 @@ def pq_goto_room(self, target_ship, retry=3): """ success = False target_title = target_ship.title().replace('_', ' ') - logger.hr(f'Enter {target_title}\'s Room', level=1) + logger.hr(f'[私人休息室-互动] 进入 {target_title} 房间', level=1) if not self._pq_goto_room_seek(target_ship): return success @@ -322,10 +325,10 @@ def pq_goto_room(self, target_ship, retry=3): break if self._pq_target_appear(): - logger.info(f'{target_title} is waiting and excited for your arrival!') + logger.info(f'[私人休息室-互动] {target_title} 正在等待你的到来!') success = True break - logger.warning(f'{target_title} is not ready, exit and try again; retry={retry - (_ + 1)}') + logger.warning(f'[私人休息室-互动] {target_title} 未就绪,退出重试; 剩余次数={retry - (_ + 1)}') self._pq_goto_room_exit() diff --git a/module/private_quarters/private_quarters.py b/module/private_quarters/private_quarters.py index fb389f7df..6406b8f2e 100644 --- a/module/private_quarters/private_quarters.py +++ b/module/private_quarters/private_quarters.py @@ -1,3 +1,37 @@ +"""私人休息室任务模块。 + +自动化私人休息室的日常任务,包括商店购买和舰娘互动。 + +主要功能: + - 购买每周商店物品(玫瑰需要金币,蛋糕需要钻石) + - 与指定舰娘执行每日亲密互动 + - OCR 读取每日互动剩余次数 + - 导航至舰娘房间并执行互动序列 + +舰娘互动机制: + - 每日有固定的亲密互动次数上限 + - 需要进入对应舰娘的房间执行互动 + - 互动包括对话选项和触摸互动 + - 可用舰娘:安克雷奇、能代、天狼星、新泽西、大凤、埃吉尔、纳希莫夫 + +商店机制: + - 玫瑰(Roses):每周限购物品,消耗金币(约 24000+) + - 蛋糕(Cake):每周限购物品,消耗钻石(约 210+) + - TW 服务器暂不支持商店功能 + +继承关系: + - PQInteract: 舰娘互动逻辑(房间导航、对话、触摸互动) + - PQShop: 商店购买逻辑(商品过滤、购买确认) + +服务器限制: + - 部分舰娘在特定服务器不可用(通过 not_supported_filter 配置) + - TW 服务器不支持商店功能 + +Pages: + 私人宿舍页面:page_private_quarters + 宿舍菜单页面:page_dormmenu +""" + import module.config.server as server from module.base.timer import Timer from module.logger import logger @@ -8,6 +42,25 @@ class PrivateQuarters(PQInteract, PQShop): + """私人休息室任务处理器。 + + 管理私人休息室的日常任务流程,组合了舰娘互动(PQInteract) + 和商店购买(PQShop)两种能力。 + + 核心流程: + 1. 从任意页面导航至宿舍菜单,再进入私人休息室。 + 2. 如配置了每周商品购买,进入商店购买玫瑰或蛋糕。 + 3. 如配置了舰娘互动,检查每日剩余次数后进入目标房间互动。 + + Attributes: + not_supported_filter (dict): 各服务器不支持的舰娘列表。 + + 配置项: + PrivateQuarters_BuyRoses: 是否购买每周玫瑰。 + PrivateQuarters_BuyCake: 是否购买每周蛋糕。 + PrivateQuarters_TargetInteract: 是否执行舰娘互动。 + PrivateQuarters_TargetShip: 目标舰娘名称(小写,如 'sirius')。 + """ # Key: str, server name # Value: list[str] not_supported_filter = { @@ -102,7 +155,7 @@ def pq_shop_weekly_items(self): in: 私人宿舍主页 out: 私人宿舍主页 """ - logger.hr(f'Get Weekly Items', level=2) + logger.hr(f'[私人休息室] 获取每周物品', level=2) # 进入商店 self._pq_shop_enter() @@ -155,32 +208,32 @@ def pq_run(self, buy_roses, buy_cake, target_interact, target_ship): in: 私人宿舍主页 out: 私人宿舍主页 """ - logger.hr(f'Private Quarters Run', level=1) + logger.hr(f'私人休息室运行', level=1) target_title = target_ship.title().replace('_', ' ') - logger.info(f'Task configured for Buy_Roses={buy_roses}, ' - f'Buy_Cake={buy_cake}, ' - f'Interact_ShipGirl={target_interact}, ' - f'Target_ShipGirl={target_title}') + logger.info(f'[私人休息室] 任务配置: 买玫瑰={buy_roses}, ' + f'买蛋糕={buy_cake}, ' + f'舰娘互动={target_interact}, ' + f'目标舰娘={target_title}') # 进入商店购买每周物品 if self.shop_filter: if server.server not in ['tw']: self.pq_shop_weekly_items() else: - logger.info(f'Private Quarters shop not supported for {server.server} server.') + logger.info(f'[私人休息室] {server.server} 服务器不支持商店功能') # 执行舰娘互动 if target_interact: # Ensure target is supported for server # Update `not_supported_filter` to enable a target if target_ship in self.not_supported_filter[server.server]: - logger.info(f'Target ship:{target_ship} not supported for {server.server} server.') + logger.info(f'[私人休息室] 目标舰娘 {target_ship} 在 {server.server} 服务器不可用') return # 获取每日剩余次数,为 0 则退出 count = self._pq_get_daily_count(retry=3) if count == 0: - logger.info('Daily intimacy count exhausted, exit subtask') + logger.info('每日亲密度次数耗尽,退出子任务') return # 执行互动 diff --git a/module/private_quarters/shop.py b/module/private_quarters/shop.py index 69d763c22..992e4eeea 100644 --- a/module/private_quarters/shop.py +++ b/module/private_quarters/shop.py @@ -1,3 +1,12 @@ +""" +私人休息室商店主逻辑。 + +编排私人宿舍商店的完整购买流程,包括商品过滤、货架扫描、 +余额检测和逐项购买。通过 Filter 和 PQShopItemGrid 实现 +商品分类与筛选,支持按优先级排序购买。 + +Pages: in: PRIVATE_QUARTERS_SHOP +""" import re from module.base.button import ButtonGrid @@ -129,7 +138,7 @@ def shop_currency(self): """ self._currency = self.status_get_gold_coins() self.gems = self.status_get_gems() - logger.info(f'Gold coins: {self._currency}, Gems: {self.gems}') + logger.info(f'[私人休息室-商店] 金币: {self._currency}, 钻石: {self.gems}') def shop_check_item(self, item): """ @@ -173,6 +182,6 @@ def shop_get_item_to_buy(self, items): if not filtered: return None - logger.attr('Item_sort', ' > '.join([str(item) for item in filtered])) + logger.attr('商品排序', ' > '.join([str(item) for item in filtered])) return filtered[0] diff --git a/module/private_quarters/status.py b/module/private_quarters/status.py index b6e742b2e..353829e0f 100644 --- a/module/private_quarters/status.py +++ b/module/private_quarters/status.py @@ -1,3 +1,12 @@ +""" +私人休息室状态 OCR 识别。 + +提供私人宿舍商店的货币余额和互动次数的 OCR 读取能力, +包括金币、钻石和每日互动剩余次数。 +通过服务器分支适配不同区域的 OCR 参数(字体颜色差异)。 + +Pages: in: PRIVATE_QUARTERS_SHOP, PRIVATE_QUARTERS_MAIN +""" import module.config.server as server from module.ocr.ocr import Digit, DigitCounter from module.private_quarters.assets import * diff --git a/module/private_quarters/ui.py b/module/private_quarters/ui.py index 9497f57d3..53abe203c 100644 --- a/module/private_quarters/ui.py +++ b/module/private_quarters/ui.py @@ -1,3 +1,12 @@ +""" +私人休息室商店界面导航。 + +提供私人宿舍商店的页面检测与导航栏控制, +包括底部标签栏(全部/礼物/家具/杂物)和左侧房间入口栏。 +通过 Navbar 实现标签页切换与状态检测。 + +Pages: in: PRIVATE_QUARTERS_SHOP +""" from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.shop.ui import ShopUI diff --git a/module/raid/assets.py b/module/raid/assets.py index 109b6a07d..5ff46026b 100644 --- a/module/raid/assets.py +++ b/module/raid/assets.py @@ -1,3 +1,5 @@ +"""突袭模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/raid/combat.py b/module/raid/combat.py index 27f2b66c2..e0cc02716 100644 --- a/module/raid/combat.py +++ b/module/raid/combat.py @@ -1,3 +1,7 @@ +"""突袭战斗处理器,继承战斗基类并适配突袭特有的战斗结算画面。 +处理大世界风格的战斗状态按钮和经验值显示。 +""" + from module.combat.assets import EXP_INFO_C, EXP_INFO_D from module.combat.combat import Combat from module.guild.assets import BATTLE_STATUS_CF, EXP_INFO_CF diff --git a/module/raid/daily.py b/module/raid/daily.py index 06749b478..8bfc19633 100644 --- a/module/raid/daily.py +++ b/module/raid/daily.py @@ -1,3 +1,13 @@ +""" +突袭每日(Daily Raid)任务模块。 + +负责按配置依次刷完各难度(easy、normal、hard)的每日突袭次数。 +支持以下功能: +- 通过 StageFilter 过滤器选择要刷的难度 +- 自动检测剩余次数并循环执行 +- EX 难度始终最后执行,并在执行前领取通关奖励 +- RPG 类型突袭无每日模式,自动禁用调度器 +""" import re from module.base.filter import Filter @@ -8,6 +18,15 @@ class RaidStage: + """ + 突袭难度阶段数据类。 + + 用于在 StageFilter 过滤器中表示一个突袭难度选项。 + + Attributes: + name (str): 难度名称,如 'easy'、'normal'、'hard'。 + """ + def __init__(self, name): self.name = name @@ -20,6 +39,17 @@ def __str__(self): class RaidDaily(RaidRun): + """ + 突袭每日任务执行器。 + + 按配置依次执行各难度的突袭每日任务。执行流程: + 1. 检查是否为 RPG 类型(RPG 无每日模式,直接禁用) + 2. 使用 StageFilter 过滤要刷的难度(默认 easy > normal > hard) + 3. 按顺序刷完每个难度的 15 次每日次数 + 4. 如果配置了 EX 难度,先领取通关奖励再执行 EX + + 继承自 RaidRun,使用其战斗执行和停止条件检查逻辑。 + """ def run(self, name=''): """ 运行突袭每日任务,依次刷完各难度次数。 @@ -28,7 +58,7 @@ def run(self, name=''): name (str): 突袭活动名称,如 'raid_20200624'。 """ if self.is_raid_rpg(): - logger.info('RPG raid has no dailies') + logger.info('[突袭-日常] RPG突袭没有每日任务') self.config.Scheduler_Enable = False self.config.task_stop() diff --git a/module/raid/raid.py b/module/raid/raid.py index 4c3110247..02b674a21 100644 --- a/module/raid/raid.py +++ b/module/raid/raid.py @@ -1,5 +1,18 @@ # 此文件处理游戏中各种限时共斗(Raid)活动关卡。 # 负责自动识别活动类型、管理入场券消耗、处理不同难度的入场逻辑,并实现了专用的 Raid 战斗流程及 PT 获取记录。 +""" +突袭(Raid)活动核心处理模块。 + +处理游戏中各种限时共斗活动关卡,包括: +- 突袭活动名称到资源前缀的映射(raid_name_shorten) +- 各难度入口按钮和 OCR 识别器的工厂函数(raid_entrance、raid_ocr、pt_ocr) +- 突袭战斗准备、入场、执行和结束的完整流程 +- 入场券使用确认弹窗处理 +- PT 积分 OCR 读取和停止条件判断 + +支持的突袭活动:ESSEX、SURUGA、BRISTOL、IRIS、ALBION、KUYBYSHEY、 +GORIZIA、HUANCHANG、RPG、CHIENWU、CHANGWU。 +""" import cv2 import numpy as np @@ -19,6 +32,14 @@ class RaidCounterPostMixin(DigitCounter): + """ + 突袭计数器后处理混入类。 + + 对 OCR 识别结果进行后处理修正,修复如 "915/"、"1515" 等 + OCR 误识别结果,将其还原为正确的 "X/15" 格式。 + 用于 CHANGWU 等新突袭活动。 + """ + def after_process(self, result): # 修正如 "915/"、"1515" 这类 OCR 误识别结果 result = result.strip('/') @@ -28,6 +49,13 @@ def after_process(self, result): class RaidCounter(DigitCounter): + """ + 突袭计数器 OCR 识别器。 + + 在预处理阶段对图像进行上下白色填充(padding),以提高 + OCR 对数字/分隔符的识别准确率。用于旧突袭活动(ESSEX、SURUGA、BRISTOL)。 + """ + def pre_process(self, image): image = super().pre_process(image) image = np.pad(image, ((2, 2), (0, 0)), mode='constant', constant_values=255) @@ -46,6 +74,12 @@ def ocr(self, image, direct_ocr=False): class HuanChangPtOcr(Digit): + """ + 环昌突袭活动 PT 积分 OCR 识别器。 + + 通过连通域分析过滤非数字区域,仅保留面积大于 60 的连通域作为有效数字, + 以处理环昌活动特殊背景干扰问题。 + """ def pre_process(self, image): """ 预处理 PT 图像:灰度化、二值化、连通域分析,过滤掉非数字区域。 @@ -218,6 +252,23 @@ def pt_ocr(raid): class Raid(MapOperation, RaidCombat, CampaignEvent): + """ + 突袭活动核心处理器。 + + 继承 MapOperation、RaidCombat 和 CampaignEvent,提供突袭活动的 + 完整战斗流程:入场、战斗准备、执行战斗、处理结束画面。 + + 主要职责: + - 停止条件判断(油量、PT 积分、金币、任务均衡器) + - 战斗准备画面处理(自动化设置、退役、情绪检查、入场券使用) + - 突袭关卡入场导航 + - 突袭战斗执行(普通模式和 EX 模式) + - PT 积分 OCR 读取和记录 + - RPG 类型突袭的特殊处理(滑动到最右侧关卡入口) + + Attributes: + _raid_has_oil_icon: 当前突袭活动是否显示油量图标(property,默认 False)。 + """ @property def _raid_has_oil_icon(self): """ @@ -236,22 +287,22 @@ def triggered_stop_condition(self, oil_check=False, pt_check=False, coin_check=F # 油量限制 if oil_check: if self.get_oil() < max(500, self.config.StopCondition_OilLimit): - logger.hr('Triggered stop condition: Oil limit') + logger.hr('触发停止条件: 石油上限') self.config.task_delay(minute=(120, 240)) return True # 活动积分限制 if pt_check: if self.event_pt_limit_triggered(): - logger.hr('Triggered stop condition: Event PT limit') + logger.hr('触发停止条件: 活动PT上限') return True # 金币限制 if coin_check and self.coin_limit_triggered(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') return True # 任务均衡器 if coin_check: if self.config.TaskBalancer_Enable and self.triggered_task_balancer(): - logger.hr('Triggered stop condition: Coin limit') + logger.hr('触发停止条件: 物资上限') self.handle_task_balancer() return True @@ -267,7 +318,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba auto (str): 自动战斗模式。 fleet_index (int): 舰队索引。 """ - logger.info('Combat preparation.') + logger.info('战斗准备') # 无需在此等待情绪恢复,已在 raid_execute_once() 中处理 @@ -296,7 +347,7 @@ def combat_preparation(self, balance_hp=False, emotion_reduce=False, auto='comba # 结束条件:战斗开始执行 pause = self.is_combat_executing() if pause: - logger.attr('BattleUI', pause) + logger.attr('战斗UI', pause) if emotion_reduce: self.emotion.reduce(fleet_index) break @@ -351,6 +402,15 @@ def raid_enter(self, mode, raid, skip_first_screenshot=True): break def raid_expected_end(self): + """ + 判断突袭战斗是否已结束。 + + 通过检测 RAID_REWARDS 奖励弹窗或返回到突袭页面来确认战斗结束。 + RPG 类型突袭检测 page_rpg_stage,其他类型检测 RAID_CHECK。 + + Returns: + bool: 战斗是否已结束。 + """ if self.appear_then_click(RAID_REWARDS, offset=(30, 30), interval=3): return False if self.is_raid_rpg(): @@ -370,7 +430,7 @@ def raid_execute_once(self, mode, raid): in: page_raid out: page_raid """ - logger.hr('Raid Execute') + logger.hr('突袭执行') self.config.override( Campaign_Name=f'{raid}_{mode}', Campaign_UseAutoSearch=False, @@ -391,7 +451,7 @@ def raid_execute_once(self, mode, raid): if mode == 'ex': backup.recover() - logger.hr('Raid End') + logger.hr('突袭结束') def raid_execute_once_with_oil_check(self, mode, raid): """ @@ -406,7 +466,7 @@ def raid_execute_once_with_oil_check(self, mode, raid): in: page_raid out: page_raid """ - logger.hr('Raid Execute') + logger.hr('突袭执行') self.config.override( Campaign_Name=f'{raid}_{mode}', Campaign_UseAutoSearch=False, @@ -422,7 +482,7 @@ def raid_execute_once_with_oil_check(self, mode, raid): self.emotion.check_reduce(1) if self.is_raid_rpg(): - logger.info('RPG raid: get oil before entering battle') + logger.info('RPG突袭: 进入战斗前获取石油') self.ui_ensure(page_campaign_menu) CampaignEvent.get_oil(self, skip_first_screenshot=True, update=False) self.ui_ensure(page_rpg_stage) @@ -434,7 +494,7 @@ def raid_execute_once_with_oil_check(self, mode, raid): if mode == 'ex': backup.recover() - logger.hr('Raid End') + logger.hr('突袭结束') def get_event_pt(self): """ @@ -459,7 +519,7 @@ def get_event_pt(self): pt = ocr.ocr(self.device.image) if timeout.reached(): - logger.warning('Wait PT timeout, assume it is') + logger.warning('等待PT超时,假设已达到') LogRes(self.config).Pt = pt return pt if pt in [70000, 70001]: @@ -468,10 +528,19 @@ def get_event_pt(self): LogRes(self.config).Pt = pt return pt else: - logger.info(f'Raid {self.config.Campaign_Event} does not support PT ocr, skip') + logger.info(f'[突袭-PT] 突袭 {self.config.Campaign_Event} 不支持PT OCR,跳过') return 0 def is_raid_rpg(self): + """ + 判断当前突袭活动是否为 RPG 类型。 + + RPG 类型突袭(raid_20240328)具有不同的 UI 布局和入口逻辑, + 需要特殊处理(如滑动操作、不同的页面检测等)。 + + Returns: + bool: 是否为 RPG 类型突袭。 + """ return self.config.Campaign_Event == 'raid_20240328' def raid_rpg_swipe(self, skip_first_screenshot=True): @@ -490,7 +559,7 @@ def raid_rpg_swipe(self, skip_first_screenshot=True): # 结束条件:已滑动到最右侧 if self.appear(RPG_RAID_EASY, offset=(10, 10)): - logger.info('RPG raid already at rightmost') + logger.info('RPG突袭已在最右') break if self.handle_story_skip(): diff --git a/module/raid/run.py b/module/raid/run.py index 73782919f..b6a307489 100644 --- a/module/raid/run.py +++ b/module/raid/run.py @@ -1,3 +1,7 @@ +"""突袭任务运行器,管理突袭的进入、次数检测和停止条件。 +支持 OCR 检测剩余次数和运行次数限制。 +""" + from module.base.timer import Timer from module.campaign.campaign_event import CampaignEvent from module.exception import ScriptEnd, ScriptError @@ -20,7 +24,7 @@ def triggered_stop_condition(self, oil_check=False, pt_check=False, coin_check=F """ # 运行次数限制 if self.run_limit and self.config.StopCondition_RunCount <= 0: - logger.hr('Triggered stop condition: Run count') + logger.hr('触发停止条件: 运行次数') self.config.StopCondition_RunCount = 0 self.config.Scheduler_Enable = False return True @@ -52,7 +56,7 @@ def get_remain(self, mode, skip_first_screenshot=True): remain = result else: remain, _, _ = result - logger.attr(f'{mode.capitalize()} Remain', remain) + logger.attr(f'{mode.capitalize()} 剩余次数', remain) if self.appear_then_click(RAID_REWARDS, offset=(30, 30), interval=3): confirm_timer.reset() @@ -95,9 +99,9 @@ def run(self, name='', mode='', total=0): # 日志输出 logger.hr(f'{name}_{mode}', level=2) if self.config.StopCondition_RunCount > 0: - logger.info(f'Count remain: {self.config.StopCondition_RunCount}') + logger.info(f'剩余次数: {self.config.StopCondition_RunCount}') else: - logger.info(f'Count: {self.run_count}') + logger.info(f'计数: {self.run_count}') # UI 切换:没有油量图标时先进入战役菜单检查停止条件 if not self._raid_has_oil_icon: @@ -118,8 +122,7 @@ def run(self, name='', mode='', total=0): # EX 模式:检查是否有足够的突袭门票 if mode == 'ex' and not self.is_raid_rpg(): if not self.get_remain(mode): - logger.info('Triggered stop condition: Zero ' - 'raid tickets to do EX mode') + logger.info('[突袭-运行] 触发停止条件: EX模式突袭门票为零') if self.config.task.command == 'Raid': with self.config.multi_set(): self.config.StopCondition_RunCount = 0 @@ -132,7 +135,7 @@ def run(self, name='', mode='', total=0): try: self.raid_execute_once(mode=mode, raid=name) except ScriptEnd as e: - logger.hr('Script end') + logger.hr('脚本结束') logger.info(str(e)) break diff --git a/module/raid/scuttle.py b/module/raid/scuttle.py index 28b17f090..3752694f2 100644 --- a/module/raid/scuttle.py +++ b/module/raid/scuttle.py @@ -1,3 +1,7 @@ +"""弃船突袭处理器,处理弃船突袭特有的战斗结算和退役逻辑。 +在突袭中自动退役低级舰船以腾出船位。 +""" + from module.combat.assets import OPTS_INFO_D, BATTLE_STATUS_D, EXP_INFO_D, BATTLE_STATUS_C, EXP_INFO_C from module.exception import ScriptError, CampaignEnd from module.logger import logger @@ -41,7 +45,7 @@ def handle_battle_status(self, drop=None): self.device.click(OPTS_INFO_D) return True if super().handle_battle_status(drop=drop): - logger.warning("Triggered normal end") + logger.warning("触发正常结束") self.triggered_normal_end = True return True @@ -134,7 +138,7 @@ def get_common_rarity_ship(self, index='all'): index=index, rarity='common', extra='enhanceable', sort='total' ) - logger.hr('FINDING SHIP') + logger.hr('搜索舰船') scanner = ShipScanner(level=(1, 31), fleet=0, status='free') scanner.disable('rarity') @@ -142,7 +146,7 @@ def get_common_rarity_ship(self, index='all'): return scanner.scan(self.device.image) def vanguard_change(self): - logger.hr('Change vanguard', level=2) + logger.hr('更换前排', level=2) for _ in self.loop(): if self.appear(DOCK_CHECK, offset=(20, 20)): break @@ -153,16 +157,16 @@ def vanguard_change(self): ship = self.get_common_rarity_ship(index='vanguard') if ship: self._ship_change_confirm(min(ship, key=lambda s: (s.level, -s.emotion)).button) - logger.info('Change vanguard success') + logger.info('[突袭-扫荡] 更换前排成功') return True else: - logger.info('Change vanguard failed, no vanguard in common rarity.') + logger.info('[突袭-扫荡] 更换前排失败,无普通稀有度前排舰船') self._dock_reset() self.ui_back(check_button=RAID_FLEET_PREPARATION) return False def flagship_change(self): - logger.hr('Change flagship', level=2) + logger.hr('更换旗舰', level=2) for _ in self.loop(): if self.appear(DOCK_CHECK, offset=(20, 20)): break @@ -173,10 +177,10 @@ def flagship_change(self): ship = self.get_common_rarity_ship(index='main') if ship: self._ship_change_confirm(min(ship, key=lambda s: (s.level, -s.emotion)).button) - logger.info('Change flagship success') + logger.info('[突袭-扫荡] 更换旗舰成功') return True else: - logger.info('Change flagship failed, no flagship in common rarity.') + logger.info('[突袭-扫荡] 更换旗舰失败,无普通稀有度旗舰舰船') self._dock_reset() self.ui_back(check_button=RAID_FLEET_PREPARATION) return False diff --git a/module/research/assets.py b/module/research/assets.py index e07460263..1f737998b 100644 --- a/module/research/assets.py +++ b/module/research/assets.py @@ -1,3 +1,5 @@ +"""科研模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/research/preset.py b/module/research/preset.py index 9827c16c1..00829f367 100644 --- a/module/research/preset.py +++ b/module/research/preset.py @@ -1,3 +1,7 @@ +"""科研过滤器预设定义,包含多种优化的科研项目选择策略。 +按蓝图需求和时间效率提供不同目标的预设过滤字符串。 +""" + FILTER_STRING_SHORTEST = '0.5 > 1 > 1.5 > 2 > 2.5 > 3 > 4 > 5 > 6 > 8 > 10 > 12' FILTER_STRING_CHEAPEST = 'Q1 > Q2 > T3 > T4 > Q4 > C6 > T6 > C8 > C12 > G1.5 > D2.5 > G2.5 > D5 > Q0.5 > G4 > D8 > H1 > H2 > H0.5 > D0.5 > H4' DICT_FILTER_PRESET = { diff --git a/module/research/preset_generator.py b/module/research/preset_generator.py index 480d1969f..d487ee708 100644 --- a/module/research/preset_generator.py +++ b/module/research/preset_generator.py @@ -1,3 +1,7 @@ +"""科研过滤器工具函数,提供过滤字符串的拆分、合并和美化。 +用于科研预设的格式化输出和解析。 +""" + import re diff --git a/module/research/project.py b/module/research/project.py index 9203d91b9..241c9db54 100644 --- a/module/research/project.py +++ b/module/research/project.py @@ -1,3 +1,27 @@ +""" +科研项目数据模型与识别。 + +本模块定义科研项目的数据结构,并提供从截图中识别科研项目的函数。 + +包含两个主要数据类: +- ResearchProject: 用于 CN/EN/TW 服务器,通过 OCR 项目名称 + 系列模板匹配 + 从项目列表截图中批量识别 5 个项目 +- ResearchProjectJp: 用于 JP 服务器,通过逐个点击详情页并使用模板匹配 + 识别系列、类型、消耗、舰船蓝图等信息(因 JP 服务器无 OCR 项目名称) + +模块还提供以下辅助函数: +- 系列编号识别:通过 Sobel 边缘检测分析罗马数字的笔画结构 +- 已完成项目检测:通过状态指示灯的颜色(绿色=已完成)判断 +- JP 详情页识别:系列、时长、类型、消耗、舰船蓝图的模板匹配 + +术语对照: + 系列(Series): 科研系列编号 S1-S9,对应不同的科研舰船池 + 类型(Genre): 项目类型代码 B/C/D/E/G/H/Q/T + 蓝图(Blueprint): 科研产出的舰船设计图,用于强化对应舰船 + DR: 决战方案(Dreamship Rarity),金色稀有度科研舰船 + PRY: 近代方案(Priority Rarity),紫色稀有度科研舰船 + 天运拟合(Fate Simulation): 使用蓝图对已满强化舰船进行的命运模拟 +""" from datetime import timedelta from scipy import signal @@ -22,21 +46,17 @@ def get_research_series_old(image, series_button=RESEARCH_SERIES): """ - 使用简单的颜色检测获取科研系列。 - 通过计算白色线条数来检测罗马数字。 + 使用简单的颜色检测获取科研系列(旧版算法)。 - ------- --- -- -- - | | | --> 3 条线 | \ / --> 3 条线 - | | | | \ / - | | | --> 3 条线 | \ / --> 2 条线 - ------- --- v + 通过计算白色线条数来检测罗马数字。 + 已被 get_research_series() 替代,保留用于兼容。 Args: - image (np.ndarray): - series_button: + image (np.ndarray): 科研列表页面的截图。 + series_button (tuple): 5 个系列标识区域的按钮定义。 Returns: - list[int]: 如 [1, 1, 1, 2, 3] + list[int]: 5 个项目的系列编号列表,如 [1, 1, 1, 2, 3]。 """ result = [] # 设置 'prominence = 50' 以忽略可能的噪声。 @@ -63,13 +83,26 @@ def get_research_series_old(image, series_button=RESEARCH_SERIES): series = 5 else: series = 0 - logger.warning(f'Unknown research series: button={button}, upper={upper}, lower={lower}') + logger.warning(f'[科研-系列] 未知的科研系列: 按钮={button}, 上限={upper}, 下限={lower}') result.append(series) return result def _get_research_series(img): + """ + 通过 Sobel 算子分析单个系列标识的笔画方向。 + + 内部辅助函数,被 get_research_series() 调用。 + 通过检测边缘梯度角判断笔画是竖直(0)还是倾斜(1), + 组合笔画序列映射到系列编号。 + + Args: + img (np.ndarray): 裁剪并缩放后的系列标识图像。 + + Returns: + int: 系列编号(1-6),无法识别返回 0。 + """ # img = rgb2luma(img) img = extract_white_letters(img) pos = img.shape[0] * 2 // 5 @@ -107,12 +140,17 @@ def _get_research_series(img): def get_research_series(image, series_button=RESEARCH_SERIES): """ - Args: - image (np.ndarray): - series_button: + 通过 Sobel 边缘检测识别科研系列编号。 + + 分析系列标识区域的罗马数字笔画方向,通过边缘梯度角判断 + 笔画是竖直(/)还是倾斜(\),从而区分不同系列。 + + Args: + image (np.ndarray): 科研列表页面的截图。 + series_button (tuple): 5 个系列标识区域的按钮定义。 Returns: - list[int]: 如 [1, 1, 1, 2, 3] + list[int]: 5 个项目的系列编号列表,如 [1, 1, 1, 2, 3]。 """ result = [] for button in series_button: @@ -126,12 +164,16 @@ def get_research_series(image, series_button=RESEARCH_SERIES): def get_research_name(image, ocr=OCR_RESEARCH): """ + 通过 OCR 识别科研列表中 5 个项目的名称。 + Args: - image (np.ndarray): - ocr (Ocr): + image (np.ndarray): 科研列表页面的截图。 + ocr (Ocr): OCR 识别器实例,默认使用 RESEARCH 专用 OCR, + 支持字母数字混合识别(字母表:0123456789BCDEGHQTMIULRF-)。 Returns: - list[str]: 如 ['D-057-UL', 'D-057-UL', 'D-057-UL', 'D-057-UL', 'D-057-UL'] + list[str]: 5 个项目的名称列表,如 + ['D-057-UL', 'C-038-RF', 'G-185-MI', 'H-339-MI', 'Q-027-MI']。 """ names = ocr.ocr(image) if not isinstance(names, list): @@ -141,17 +183,24 @@ def get_research_name(image, ocr=OCR_RESEARCH): def get_research_finished(image): """ + 通过状态指示灯颜色检测已完成的科研项目。 + + 遍历 5 个项目的状态指示灯,通过 RGB 颜色通道分析判断状态: + - 绿色(G 通道最大)= 已完成 + - 蓝色(B 通道最大)= 运行中 + - 其他颜色 = 异常,跳过 + Args: - image (np.ndarray): + image (np.ndarray): 科研列表页面的截图。 Returns: - int: 已完成项目的索引,0 到 4。如果没有已完成项目则返回 None。 + int: 已完成项目的索引(0-4)。如果没有已完成项目返回 None。 """ for index in [2, 1, 3, 0, 4]: button = RESEARCH_STATUS[index] color = get_color(image, button.area) if max(color) - min(color) < 40: - logger.warning(f'Unexpected color: {color}') + logger.warning(f'[科研-状态] 异常颜色: {color}') continue color_index = np.argmax(color) # R, G, B if color_index == 1: @@ -159,7 +208,7 @@ def get_research_finished(image): elif color_index == 2: continue # 蓝色 else: - logger.warning(f'Unexpected color: {color}') + logger.warning(f'[科研-状态] 异常颜色: {color}') continue return None @@ -167,14 +216,18 @@ def get_research_finished(image): def parse_time(string): """ + 解析时间字符串为 timedelta 对象。 + Args: - string (str): 如 01:00:00, 05:47:10, 17:50:51。 + string (str): 时间字符串,格式为 'HH:MM:SS', + 如 '01:00:00'、'05:47:10'、'17:50:51'。 + Returns: - timedelta: datetime.timedelta 实例。 + timedelta: 解析后的时间间隔对象,解析失败返回 None。 """ result = re.search('(\d+):(\d+):(\d+)', string) if not result: - logger.warning(f'Invalid time string: {string}') + logger.warning(f'[科研-时间] 无效的时间字符串: {string}') return None else: result = [int(s) for s in result.groups()] @@ -183,14 +236,18 @@ def parse_time(string): def match_template(image, template, area, offset=30, similarity=0.85): """ + 在截图的指定区域内进行模板匹配。 + Args: - image (np.ndarray): 截图 - template (np.ndarray): - area (tuple): 图像裁剪区域。 - offset (int, tuple): 检测区域偏移量。 - similarity (float): 0-1。相似度。低于此值将返回 float(0)。 + image (np.ndarray): 完整截图。 + template (np.ndarray): 待匹配的模板图像。 + area (tuple): 图像裁剪区域 (x1, y1, x2, y2)。 + offset (int, tuple): 检测区域的扩展偏移量,用于扩大搜索范围。 + 整数表示上下对称偏移,元组表示 (左右, 上下) 独立偏移。 + similarity (float): 相似度阈值(0-1),低于此值返回 0.0。 + Returns: - similarity (float): + float: 匹配相似度(0-1),低于阈值返回 0.0。 """ if isinstance(offset, tuple): offset = np.array((-offset[0], -offset[1], offset[0], offset[1])) @@ -206,13 +263,16 @@ def match_template(image, template, area, offset=30, similarity=0.85): def get_research_series_jp_old(image): """ - 与 get_research_series 基本相同,区别在于按钮区域。 + 从 JP 服务器详情页识别系列编号(旧版算法)。 + + 与 get_research_series 基本相同,区别在于按钮区域和无需缩放。 + 已被 get_research_series_jp() 替代,保留用于兼容。 Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 Returns: - str: 系列编号,如 "S4" + str: 系列标识,如 "S4"。 """ # 设置 'prominence = 50' 以忽略可能的噪声。 parameters = {'height': 160, 'prominence': 50, 'width': 1} @@ -236,18 +296,22 @@ def get_research_series_jp_old(image): series = 5 else: series = 0 - logger.warning(f'Unknown research series: upper={upper}, lower={lower}') + logger.warning(f'未知的科研系列: upper={upper}, lower={lower}') return f'S{series}' def get_research_series_jp(image): """ + 从 JP 服务器详情页识别系列编号。 + + 通过模板匹配从详情页的系列标识区域识别系列编号。 + Args: - image: + image (np.ndarray): 科研详情页的截图。 Returns: - str: 系列编号,如 "S4" + str: 系列标识,如 "S4"。 """ series = get_detail_series(image) return f'S{series}' @@ -255,11 +319,13 @@ def get_research_series_jp(image): def get_research_duration_jp(image): """ + 通过 OCR 识别 JP 服务器详情页中的科研时长。 + Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 Returns: - duration (int): 秒数 + int: 科研时长,单位为秒。 """ ocr = Duration(DURATION_DETAIL) duration = ocr.ocr(image).total_seconds() @@ -268,11 +334,15 @@ def get_research_duration_jp(image): def get_research_genre_jp(image): """ + 通过模板匹配识别 JP 服务器详情页中的科研类型。 + + 遍历所有类型模板(B/C/D/E/G/H/Q/T),找到匹配度最高的类型。 + Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 Returns: - genre (string): + str: 类型代码,如 'd'、'c'、'g'。无法识别返回空字符串。 """ genre = '' for button in RESEARCH_DETAIL_GENRE: @@ -281,22 +351,24 @@ def get_research_genre_jp(image): genre = button.name.split("_")[2] break if not genre: - logger.warning(f'Not able to recognize research genre!') + logger.warning(f'无法识别科研类型!') return genre def get_research_cost_jp(image): """ - 当科研有 1 个消耗项时,其尺寸为 78x78。 - 当科研有 2 个消耗项时,每个尺寸为 77x77。 - 但金币、魔方和部件的模板差异较大, - 因此在匹配时简单设置较低的阈值即可。 + 通过模板匹配识别 JP 服务器详情页中的资源消耗。 + + 检测详情页中是否包含金币、魔方和部件的消耗图标。 + 当科研有 1 个消耗项时模板尺寸为 78x78,有 2 个时为 77x77, + 因此匹配阈值设为较低的 0.8 以提高容错率。 Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 Returns: - costs (string): dict + dict: 消耗信息字典,键为 'need_coin'、'need_cube'、'need_part', + 值为 bool 表示是否需要该资源。 """ size_template = (78, 78) area_template = (0, 0, 78, 57) @@ -327,14 +399,17 @@ def get_research_cost_jp(image): def get_research_ship_jp(image): """ - 注意 2.5、5 和 8 小时的 D 系列科研有 4 个物品,而 0.5 小时的有 3 个, - 因此 DETAIL_BLUEPRINT 按钮不应只覆盖 4 个物品中的第一个。 + 通过模板匹配识别 JP 服务器详情页中的舰船蓝图。 + + 从蓝图模板库中找到与详情页蓝图区域最匹配的舰船。 + 注意 2.5/5/8 小时的 D 系列有 4 个物品,0.5 小时有 3 个, + 因此 DETAIL_BLUEPRINT 按钮不应只覆盖第一个物品。 Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 Returns: - ship (string): + str: 舰船名称,如 'azuma'、'drake'。无法识别返回空字符串。 """ folder = './assets/research_blueprint' templates = load_folder(folder) @@ -350,17 +425,22 @@ def get_research_ship_jp(image): similarity = sim ship = name if ship == '': - logger.warning(f'Ship recognition failed') + logger.warning(f'舰船识别失败') return ship def research_jp_detect(image): """ + 从 JP 服务器详情页完整识别一个科研项目。 + + 组合调用系列、时长、类型、消耗和舰船识别函数, + 生成完整的 ResearchProjectJp 对象。 + Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研详情页的截图。 - Return: - project (ResearchProjectJp): + Returns: + ResearchProjectJp: 识别到的科研项目对象。 """ project = ResearchProjectJp() project.series = get_research_series_jp(image) @@ -377,27 +457,60 @@ def research_jp_detect(image): project.ship_rarity = 'dr' if project.ship in project.DR_SHIP else 'pry' project.name = f'{project.series}-{project.genre}-{project.duration}{project.ship}' if not project.check_valid(): - logger.warning(f'Invalid research {project}') + logger.warning(f'[科研-项目] 无效的科研项目 {project}') return project def research_detect(image): """ + 从科研列表截图中批量识别 5 个科研项目。 + + 通过 OCR 识别项目名称,模板匹配识别系列编号, + 组合生成 ResearchProject 对象列表。 + Args: - image (np.ndarray): 截图 + image (np.ndarray): 科研列表页面的截图。 - Return: - list[ResearchProject]: + Returns: + list[ResearchProject]: 5 个科研项目对象的列表。 """ projects = [] for name, series in zip(get_research_name(image), get_research_series_3(image)): project = ResearchProject(name=name, series=series) - logger.attr('Project', project) + logger.attr('科研项目', project) projects.append(project) return projects class ResearchProject: + """ + 科研项目数据模型,用于 CN/EN/TW 服务器。 + + 通过项目名称(如 'D-057-UL')和系列编号(如 3)在项目数据库中 + 查询匹配的项目信息,解析出类型、编号、时长、消耗需求和产出舰船等属性。 + + OCR 识别可能存在错误,构造函数中包含大量的名称修正逻辑, + 例如:'G-185-MI' -> 'C-185-MI'、'D-022-ML' -> 'D-022-MI' 等。 + + Attributes: + valid (bool): 项目是否有效(在数据库中找到匹配项)。 + raw_series (int): 原始系列编号(1-9)。 + series (str): 格式化的系列标识,如 'S3'。 + name (str): 修正后的项目名称,如 'D-057-UL'。 + genre (str): 项目类型代码,如 'D'、'C'、'G'。 + number (str): 项目编号,如 '057'。 + duration (str): 项目时长(小时),如 '0.5'、'2'、'8'。 + ship (str): 产出的舰船名称,如 'azuma'、'drake'。 + 非 D 系列项目通常为空字符串。 + ship_rarity (str): 舰船稀有度,'dr' 或 'pry'。 + 仅 D 系列有蓝图产出时有值。 + need_coin (bool): 是否消耗金币。 + need_cube (bool): 是否消耗魔方。 + need_part (bool): 是否消耗部件。 + task (str): 项目特殊要求描述,如 'Scrap 8 pieces of gear.'。 + equipment_amount (int): 需要拆解的装备数量(E 系列),0 表示无要求。 + commission_amount (int): 需要完成的委托数量(T 系列),0 表示无要求。 + """ REGEX_SHIP = re.compile( '(' 'neptune|monarch|ibuki|izumo|roon|saintlouis' @@ -460,7 +573,7 @@ def __init__(self, name, series): # 'D-057-UL' self.name = self.check_name(name) if self.name != name: - logger.info(f'Research name {name} is revised to {self.name}') + logger.info(f'[科研-名称] 科研名称 {name} 修正为 {self.name}') # 'D' self.genre = '' # '057' @@ -500,7 +613,7 @@ def __init__(self, name, series): break if not matched: - logger.warning(f'Invalid research {self}') + logger.warning(f'[科研-项目] 无效的科研项目 {self}') self.valid = False def __str__(self): @@ -514,11 +627,17 @@ def __eq__(self, other): def check_name(self, name): """ + 修正 OCR 识别中的常见项目名称错误。 + + 处理多种 OCR 误识别情况,包括:前缀混淆(G/D/C/L)、 + 数字误识别(D->0, O->0, S->5)、后缀修正(ML->MI, 0C->UL)、 + 特定服务器的已知错误等。 + Args: - name (str): + name (str): OCR 识别的原始项目名称。 Returns: - str: + str: 修正后的项目名称,如 'D-057-UL'。 """ name = name.strip('-') # G-185-MI, D-T85-MI -> C-185-MI @@ -578,12 +697,18 @@ def check_name(self, name): def get_data(self, name, series): """ + 从项目数据库中查询匹配的科研项目数据。 + + 按优先级依次尝试精确匹配、前缀修正(G/C/D 混淆)、 + 后缀模糊匹配等多种策略,以应对 OCR 识别错误。 + Args: - name (str): 如 'D-057-UL' - series (int): 如 1, 2, 3 + name (str): 修正后的项目名称,如 'D-057-UL'。 + series (int): 系列编号,如 1, 2, 3。 Yields: - dict: + dict: 匹配到的项目数据字典,包含 name、series、time、 + task、input、output 等字段。 """ for data in LIST_RESEARCH_PROJECT: if (data['series'] == series) and (data['name'] == name): @@ -592,7 +717,7 @@ def get_data(self, name, series): if len(name) and name[0].isdigit(): for t in 'QGE': name1 = f'{t}-{self.name}' - logger.info(f'Testing the most similar candidate {name1}') + logger.info(f'[科研-匹配] 测试最相似的候选 {name1}') for data in LIST_RESEARCH_PROJECT: if (data['series'] == series) and (data['name'] == name1): self.name = name1 @@ -636,6 +761,36 @@ def commission_amount(self): class ResearchProjectJp: + """ + 科研项目数据模型,用于 JP 服务器。 + + JP 服务器的科研项目名称无法通过 OCR 识别,因此使用模板匹配 + 逐个检测详情页中的系列、类型、消耗和舰船蓝图信息。 + 项目名称由检测结果组合生成,格式为 '{series}-{genre}-{duration}{ship}'。 + + Attributes: + valid (bool): 项目是否有效(通过 check_valid() 验证)。 + name (str): 组合生成的项目标识,如 'S4-D-0.5azuma'。 + series (str): 格式化的系列标识,如 'S4'。 + genre (str): 项目类型代码,如 'd'、'c'、'g'。 + number (str): 项目编号,JP 服务器通常为空字符串。 + duration (str): 项目时长(小时),如 '0.5'、'2'、'8'。 + ship (str): 产出的舰船名称,如 'azuma'。 + ship_rarity (str): 舰船稀有度,'dr' 或 'pry'。 + need_coin (bool): 是否消耗金币。 + need_cube (bool): 是否消耗魔方。 + need_part (bool): 是否消耗部件。 + task (str): 项目特殊要求,JP 服务器通常为空字符串。 + equipment_amount (int): 需要拆解的装备数量(E 系列)。 + commission_amount (int): 需要完成的委托数量(T 系列)。 + + 类属性: + GENRE (list[str]): 所有有效的项目类型代码。 + DURATION (list[str]): 所有有效的项目时长。 + SHIP_S1 ~ SHIP_S9 (list[str]): 各系列对应的舰船名称列表。 + SHIP_ALL (list[str]): 所有系列的舰船名称合并列表。 + DR_SHIP (list[str]): 所有 DR(决战方案)舰船名称。 + """ GENRE = ['b', 'c', 'd', 'e', 'g', 'h', 'q', 't'] DURATION = ['0.5', '1', '1.5', '2', '2.5', '3', '4', '5', '6', '8', '12'] SHIP_S1 = ['neptune', 'monarch', 'ibuki', 'izumo', 'roon', 'saintlouis'] @@ -674,6 +829,15 @@ def __init__(self): self.task = '' def check_valid(self): + """ + 验证 JP 服务器科研项目的有效性。 + + 检查系列、类型、时长是否在有效范围内, + 以及 D 系列项目是否识别到了舰船蓝图。 + + Returns: + bool: 项目是否有效。 + """ self.valid = False if self.series.lower() == "s0": return False diff --git a/module/research/project_data.py b/module/research/project_data.py index cb1c56b92..55b90d55a 100644 --- a/module/research/project_data.py +++ b/module/research/project_data.py @@ -1,3 +1,5 @@ +"""科研模块。""" + # This file was automatically generated by dev_tools/research_extractor.py. # Don't modify it manually. diff --git a/module/research/research.py b/module/research/research.py index ec97c4ea9..a0b8c0bb3 100644 --- a/module/research/research.py +++ b/module/research/research.py @@ -1,3 +1,23 @@ +""" +科研任务主处理器。 + +本模块实现科研系统的完整自动化流程,包括: +- 检测已完成的科研项目并领取奖励 +- 根据用户配置的筛选规则选择最优科研项目 +- 启动科研项目并加入科研队列 +- 填充科研队列(最多 5 个队列槽位 + 1 个队列外项目) +- 处理特殊项目类型(E 系列需拆解装备、T 系列需完成委托) +- 延迟科研策略:资源不足时等待队列自然消耗 + +核心类 `RewardResearch` 继承自 `ResearchSelector`(项目筛选与选择)、 +`ResearchQueue`(队列管理)和 `StorageHandler`(装备拆解), +是 `AzurLaneAutoScript` 中 `research` 任务的执行入口。 + +术语对照: + 科研队列(Research Queue): 最多容纳 5 个排队项目的队列 + 第 6 个项目: 不在队列中、直接运行的额外科研项目 + 强制模式(enforce): 当筛选结果为空时,放宽条件选择项目 +""" from datetime import timedelta import numpy as np @@ -21,6 +41,34 @@ class RewardResearch(ResearchSelector, ResearchQueue, StorageHandler): + """ + 科研任务主处理器,负责科研项目的完整生命周期管理。 + + 通过多重继承组合以下能力: + - ResearchSelector: 科研项目检测、筛选和优先级排序 + - ResearchQueue: 科研队列的添加、状态检测和奖励领取 + - StorageHandler: 装备拆解(用于 E 系列科研的前提条件) + + 科研流程概览: + 1. 导航到科研页面 + 2. 进入队列,领取已完成项目的奖励 + 3. 处理挂起的 T 系列委托科研 + 4. 领取第 6 个(队列外)项目的奖励 + 5. 循环填充队列直到 5 个槽位用满 + 6. 计算下次调度时间 + + Attributes: + _research_project_offset (int): 项目列表在屏幕上的偏移量, + 用于在点击非中央位置的项目时进行索引修正。 + _research_finished_index (int): 已完成项目的索引(0-4), + 用于定位已完成项目在屏幕上的位置。 + research_project_started (ResearchProject): 最近一次成功启动的 + 科研项目对象,未启动项目时为 None。 + enforce (bool): 是否处于强制模式。当筛选结果为空时, + 自动切换到强制模式以放宽条件选择项目。 + end_time (datetime): 队列中第一个科研项目的预计完成时间, + 用于计算任务调度延迟。 + """ _research_project_offset = 0 _research_finished_index = 2 research_project_started = None # ResearchProject 对象 @@ -36,7 +84,7 @@ def research_has_finished(self): """ index = get_research_finished(self.device.image) if index is not None: - logger.attr('Research_finished', index) + logger.attr('科研已完成', index) self._research_finished_index = index return True else: @@ -44,18 +92,23 @@ def research_has_finished(self): def research_reset(self, drop=None, skip_first_screenshot=True): """ + 重置科研项目列表,刷新可用的科研项目。 + + 仅在重置功能可用时执行(RESET_AVAILABLE 按钮可见)。 + 重置后项目列表将更新,之前的筛选结果失效。 + Args: - drop (DropImage): - skip_first_screenshot (bool): + drop (DropImage): 掉落记录对象,记录重置前的截图。 + skip_first_screenshot (bool): 是否跳过首次截图。 Returns: - bool: 重置是否成功 + bool: 重置是否成功执行。若重置功能不可用则返回 False。 """ if not self.appear(RESET_AVAILABLE, threshold=10): - logger.info('Research reset unavailable') + logger.info('[科研-重置] 科研重置不可用') return False - logger.info('Research reset') + logger.info('[科研-重置] 科研重置') drop.add(self.device.image) executed = False while 1: @@ -81,13 +134,21 @@ def research_reset(self, drop=None, skip_first_screenshot=True): def research_enforce(self, drop=None, add_queue=True): """ + 强制选择一个科研项目,忽略部分筛选条件。 + + 当正常筛选结果为空时,切换到强制模式并使用宽松的筛选规则 + 重新选择项目。确保始终有项目在运行。 + Args: - drop (DropImage): + drop (DropImage): 掉落记录对象。 add_queue (bool): 是否加入队列。 第 6 个项目无法加入队列,因此需要此开关。 + + Returns: + bool: 是否成功选择项目。 """ if not self.enforce: - logger.info('Enforce choosing research project') + logger.info('[科研-强制] 强制选择科研项目') self.enforce = True return self.research_select(self.research_sort_filter(self.enforce), drop=drop, add_queue=add_queue) @@ -106,7 +167,7 @@ def research_select(self, priority, drop=None, add_queue=True): bool: 如果已重置则返回 False """ if not len(priority): - logger.info('No research project satisfies current filter') + logger.info('[科研-选择] 没有符合当前筛选条件的科研项目') return self.research_enforce(drop=drop, add_queue=add_queue) for project in priority: # 优先级示例:['reset', 'shortest'] @@ -125,7 +186,7 @@ def research_select(self, priority, drop=None, add_queue=True): self.research_select(self.research_sort_cheapest(self.enforce), drop=drop, add_queue=add_queue) else: - logger.warning(f'Unknown select method: {project}') + logger.warning(f'[科研-选择] 未知的选择方法: {project}') return True elif project.genre.upper() in ['C', 'T'] and not self.enforce: return self.research_enforce(drop=drop, add_queue=add_queue) @@ -135,15 +196,15 @@ def research_select(self, priority, drop=None, add_queue=True): if ret: return True elif ret is not None and self.config.Research_RemainingCommissions > 0: - logger.info('Delay research due to genre T research') + logger.info('[科研-延迟] 因T类科研延迟研究') return True elif ret is not None and self.research_delay_check(): - logger.info('Delay research when resources not enough and queue not empty') + logger.info('[科研-延迟] 资源不足且队列未空,延迟研究') return True else: continue - logger.info('No research project started') + logger.info('[科研-选择] 没有启动科研项目') return self.research_enforce(drop=drop, add_queue=add_queue) def research_delay_check(self): @@ -183,16 +244,16 @@ def research_project_start(self, project, add_queue=True, skip_first_screenshot= in: is_in_research out: is_in_research """ - logger.hr('Research project start') - logger.info(f'Research project: {project}') + logger.hr('开始科研项目', level=2) + logger.info(f'[科研-启动] 科研项目: {project}') if isinstance(project, int): index = project elif project in self.projects: index = self.projects.index(project) else: - logger.warning(f'The project to start: {project} is not in known projects') + logger.warning(f'[科研-启动] 要启动的项目: {project} 不在已知项目列表中') return None - logger.info(f'Research project: {index}') + logger.info(f'[科研-启动] 科研项目索引: {index}') self.interval_clear([RESEARCH_START]) self.popup_interval_clear() available = False @@ -209,7 +270,7 @@ def research_project_start(self, project, add_queue=True, skip_first_screenshot= # 此处不使用 interval,RESEARCH_CHECK 已在 5 秒前出现过 if click_timer.reached() and self.is_in_research(): i = (index - self._research_project_offset) % 5 - logger.info(f'Project offset: {self._research_project_offset}, project {index} is at {i}') + logger.info(f'[科研-启动] 项目偏移: {self._research_project_offset}, 项目 {index} 位于 {i}') self.device.click(RESEARCH_ENTRANCE[i]) self.ensure_research_stable() click_count += 1 @@ -223,9 +284,9 @@ def research_project_start(self, project, add_queue=True, skip_first_screenshot= # 结束条件 if click_count >= 3: - logger.error('Unable to start a research project after 3 trail, ' - 'probably because there is a research running but requirements not satisfied, ' - 'or a research finished') + logger.error('[科研-启动] 尝试3次后仍无法启动科研项目,' + '可能是因为已有科研在运行但条件未满足,' + '或科研已完成') raise GameTooManyClickError if self.appear(RESEARCH_STOP, offset=(20, 20)): # RESEARCH_STOP 是半透明按钮,颜色会随背景变化 @@ -242,7 +303,7 @@ def research_project_start(self, project, add_queue=True, skip_first_screenshot= return True if not available and max_rgb <= 235 \ and self.appear(RESEARCH_UNAVAILABLE, offset=(5, 20)): - logger.info('Not enough resources to start this project') + logger.info('[科研-启动] 资源不足,无法启动此项目') self.research_detail_quit() self.research_project_started = None self._research_project_offset = (index - 2) % 5 @@ -269,8 +330,8 @@ def research_project_start_with_requirements(self, project, add_queue=True): if isinstance(project, int): return self.research_project_start(project, add_queue=add_queue) elif project.genre == 'E' and project.equipment_amount > 0: - logger.info(f'Going to start an E series research: {project} ' - f'and disassemble {project.equipment_amount} equipment') + logger.info(f'[科研-E系列] 准备启动E系列科研: {project} ' + f'并拆解 {project.equipment_amount} 个装备') # 启动项目 self.research_project_start(project, add_queue=False) # 拆解装备 @@ -281,10 +342,10 @@ def research_project_start_with_requirements(self, project, add_queue=True): # 加入队列 result = self.research_project_start(project, add_queue=add_queue) if result is None: - logger.error('Research project is missing after disassemble equipment') + logger.error('[科研-E系列] 拆解装备后科研项目丢失') return result elif project.genre == 'T': - logger.info(f'Going to start a T series research: {project}') + logger.info(f'[科研-T系列] 准备启动T系列科研: {project}') self.research_project_start(project, add_queue=False) self.config.Research_RemainingCommissions = project.commission_amount self.research_project_started = None @@ -295,8 +356,13 @@ def research_project_start_with_requirements(self, project, add_queue=True): def research_receive(self, skip_first_screenshot=True): """ + 领取科研主页中已完成项目的奖励。 + + 检测已完成的科研项目,点击进入并领取奖励物品, + 支持掉落记录功能。若项目时间已到但条件未满足则跳过。 + Args: - skip_first_screenshot: + skip_first_screenshot (bool): 是否跳过首次截图。 Pages: in: page_research, stable, with project finished. @@ -306,7 +372,7 @@ def research_receive(self, skip_first_screenshot=True): bool: 成功领取奖励返回 True。 项目条件未满足返回 False。 """ - logger.hr('Research receive', level=3) + logger.hr('领取科研奖励', level=3) with self.stat.new( genre='research', method=self.config.DropRecord_ResearchRecord ) as record: @@ -327,7 +393,7 @@ def research_receive(self, skip_first_screenshot=True): self.device.click(RESEARCH_ENTRANCE[self._research_finished_index]) if self.appear(RESEARCH_STOP, offset=(20, 20)): - logger.info('The research time is up, but requirements are not satisfied') + logger.info('[科研-领取] 科研时间已到,但条件未满足') self.research_project_started = None self.research_detail_quit() return False @@ -356,17 +422,22 @@ def research_receive(self, skip_first_screenshot=True): def queue_receive(self, skip_first_screenshot=True): """ + 领取科研队列中所有已完成项目的奖励。 + + 遍历队列页面,依次领取已完成项目的奖励物品, + 支持掉落记录功能用于统计追踪。 + Args: - skip_first_screenshot: + skip_first_screenshot (bool): 是否跳过首次截图。 Pages: in: is_in_queue out: is_in_queue Returns: - int: 领取奖励的科研项目数量 + int: 领取奖励的科研项目数量。 """ - logger.hr('Queue receive', level=1) + logger.hr('领取队列奖励', level=1) total = 0 with self.stat.new( genre='research', method=self.config.DropRecord_ResearchRecord @@ -407,7 +478,7 @@ def queue_receive(self, skip_first_screenshot=True): total += 1 continue else: - logger.info(f'{appear_button} appeared') + logger.info(f'[科研-领取] {appear_button} 出现') record_button = appear_button item_confirm.reset() else: @@ -430,7 +501,7 @@ def queue_receive(self, skip_first_screenshot=True): if total <= 0: drop.clear() - logger.info(f'Received rewards from {total} projects') + logger.info(f'[科研-队列] 从 {total} 个项目领取了奖励') return total def queue_quit(self, *args, **kwargs): @@ -455,18 +526,23 @@ def research_project_list_init(self, from_queue=False): def research_queue_append(self, drop=None, add_queue=True): """ + 从项目列表中选择一个科研项目并启动。 + + 初始化项目列表、执行筛选和选择,最多尝试 2 次。 + 成功启动后记录已启动的项目对象。 + Args: - drop (DropImage): + drop (DropImage): 掉落记录对象。 add_queue (bool): 是否加入队列。 第 6 个项目无法加入队列,因此需要此开关。 Returns: - bool: 是否成功启动项目 + bool: 是否成功启动项目。 """ self.research_project_started = None project_record = None for _ in range(2): - logger.hr('Research select', level=2) + logger.hr('选择科研项目', level=2) self.research_project_list_init(from_queue=True) project_record = self.device.image priority = self.research_sort_filter() @@ -491,7 +567,7 @@ def research_fill_queue(self): Pages: in: is_in_research """ - logger.hr('Research fill queue', level=1) + logger.hr('填充科研队列', level=1) total = 0 with self.stat.new( genre='research', method=self.config.DropRecord_ResearchRecord @@ -502,7 +578,7 @@ def research_fill_queue(self): if success: total += 1 else: - logger.info(f'Unable to start a project, stop filling queue, queue added: {total}') + logger.info(f'[科研-队列] 无法启动项目,停止填充队列,已添加: {total}') return total else: break @@ -510,12 +586,12 @@ def research_fill_queue(self): # 运行第 6 个项目 status = self.get_research_status(self.device.image) if 'waiting' not in status: - logger.info('Select the 6th research') + logger.info('[科研-第6个] 选择第6个科研') self.research_queue_append(drop=drop, add_queue=False) else: - logger.info('6th research already waiting') + logger.info('[科研-第6个] 第6个科研已在等待中') - logger.info(f'Research queue full filled, queue added: {total}') + logger.info(f'[科研-队列] 科研队列已填满,已添加: {total}') return total def receive_6th_research(self, skip_first_screenshot=True): @@ -523,7 +599,7 @@ def receive_6th_research(self, skip_first_screenshot=True): Returns: bool: 是否成功 """ - logger.hr('Receive 6th research', level=2) + logger.hr('领取第6个科研', level=2) # 等待动画 timeout = Timer(2, count=6).start() @@ -534,7 +610,7 @@ def receive_6th_research(self, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning('receive_6th_research wait timeout') + logger.warning('[科研-第6个] 等待超时') break status = self.get_research_status(self.device.image) @@ -555,12 +631,12 @@ def receive_6th_research(self, skip_first_screenshot=True): # 检查是否已完成 if self.research_has_finished(): - logger.info(f'6th research finished at: {self._research_finished_index}') + logger.info(f'[科研-第6个] 第6个科研已完成,位置: {self._research_finished_index}') success = self.research_receive() if not success: return False else: - logger.info('No research has finished') + logger.info('[科研-第6个] 没有科研完成') # 检查是否处于等待或运行状态 status = self.get_research_status(self.device.image) @@ -568,16 +644,27 @@ def receive_6th_research(self, skip_first_screenshot=True): if self.get_queue_slot() > 0: self.research_project_start(status.index('waiting')) else: - logger.info('Queue full, stop appending waiting research') + logger.info('[科研-第6个] 队列已满,停止追加等待中的科研') if 'running' in status: if self.get_queue_slot() > 0: self.research_project_start(status.index('running')) else: - logger.info('Queue full, stop appending running research') + logger.info('[科研-第6个] 队列已满,停止追加运行中的科研') return True def handle_pending_t_research(self): + """ + 处理挂起的 T 系列委托科研项目。 + + T 系列科研需要完成委托才能启动。此方法检查是否有待处理的 + T 系列科研,并尝试启动它。启动成功后将委托数写入配置, + 由委托任务模块负责完成。 + + Returns: + bool: True 表示 T 类科研已处理完毕或无需处理, + False 表示 T 类科研仍在等待中。 + """ if self.config.Research_RemainingCommissions <= -1: return True if self.config.Research_RemainingCommissions > 0: @@ -586,14 +673,14 @@ def handle_pending_t_research(self): slot = self.get_queue_slot() add_queue = slot > 0 if not self.research_project_start(2, add_queue=add_queue): - logger.warning('Failed to start pending T research') + logger.warning('[科研-T系列] 启动挂起的T类科研失败') return False if add_queue: self.config.Research_RemainingCommissions = -1 return True - logger.info('T research is running outside the full queue') + logger.info('[科研-T系列] T类科研正在队列外运行') return False def run(self): diff --git a/module/research/rqueue.py b/module/research/rqueue.py index 0501d25d2..fe440372f 100644 --- a/module/research/rqueue.py +++ b/module/research/rqueue.py @@ -1,3 +1,19 @@ +""" +科研队列管理。 + +本模块管理科研系统的队列功能,包括: +- 将已启动的科研项目添加到队列 +- 检测队列中各槽位的状态(已完成/运行中/等待中/空) +- 领取队列中已完成项目的奖励 +- 获取队列中第一个项目的剩余时间和预计完成时间 + +科研队列最多容纳 5 个项目,采用 FIFO 顺序运行。 +队列中第一个项目运行完成后,等待中的项目自动开始。 + +术语对照: + 科研队列(Research Queue): 最多容纳 5 个排队项目的队列 + 槽位(Slot): 队列中的位置,从下到上编号 0-4 +""" from module.base.button import ButtonGrid from module.base.decorator import cached_property, Config from module.base.utils import get_color @@ -12,6 +28,16 @@ class ResearchQueue(ResearchUI): + """ + 科研队列管理器,负责队列操作和状态检测。 + + 提供队列项目的添加、状态检测、奖励领取和时间查询等功能。 + 通过颜色检测识别队列左侧的状态图标来判断各槽位状态。 + + Attributes: + queue_status_grids (ButtonGrid): 队列状态图标的按钮网格, + 因各服务器 UI 布局差异,通过 @Config.when 按服务器分别定义。 + """ def research_queue_add(self, skip_first_screenshot=True): """ Returns: @@ -22,7 +48,7 @@ def research_queue_add(self, skip_first_screenshot=True): in: RESEARCH_QUEUE_ADD (is_in_research, DETAIL_NEXT) out: is_in_research and stabled """ - logger.hr('Research queue add') + logger.hr('加入科研队列') # POPUP_CONFIRM has just been clicked in research_project_start() self.popup_interval_clear() self.interval_clear([RESEARCH_QUEUE_ADD]) @@ -41,7 +67,7 @@ def research_queue_add(self, skip_first_screenshot=True): self.device.click(RESEARCH_QUEUE_ADD) continue else: - logger.info('Project requirements not satisfied, cancel it') + logger.info('[科研-队列] 项目条件未满足,取消') self.research_detail_cancel() return False @@ -127,7 +153,7 @@ def _queue_status_detect(self, button): return 'running' else: return 'empty' - logger.warning(f'Unknown queue status from {button}, assume running') + logger.warning(f'[科研-队列] 未知的队列状态,来自 {button},假设为运行中') return 'running' def get_queue_slot(self): @@ -139,14 +165,14 @@ def get_queue_slot(self): in: is_in_queue """ status = [self._queue_status_detect(button) for button in self.queue_status_grids.buttons] - logger.info(f'Research queue: {status}') + logger.info(f'[科研-队列] 科研队列: {status}') status = status[::-1] for index, s in enumerate(status): if s != 'empty': - logger.attr('Research queue slot', index) + logger.attr('科研队列槽位', index) return index index = len(status) - logger.attr('Research queue slot', index) + logger.attr('科研队列槽位', index) return index def get_research_ended(self): @@ -161,14 +187,14 @@ def get_research_ended(self): GameBugError: """ if self.image_color_count(QUEUE_REMAIN, color=(123, 125, 123), threshold=235, count=100): - logger.error('The first research of queue is not running,' - 'probably a game bug from AL,' - 'restart the game should fix it.') + logger.error('[科研-队列] 队列中第一个科研未运行,' + '可能是游戏bug,' + '重启游戏应该能修复。') raise GameBugError if not self.image_color_count(QUEUE_REMAIN, color=(255, 255, 255), threshold=221, count=100): - logger.info('Research queue empty') + logger.info('[科研-队列] 科研队列为空') return current_time() end_time = current_time() + OCR_QUEUE_REMAIN.ocr(self.device.image) - logger.info(f'The first research ended at: {end_time}') + logger.info(f'[科研-队列] 第一个科研结束时间: {end_time}') return end_time diff --git a/module/research/selector.py b/module/research/selector.py index 185a942e0..5b75fa818 100644 --- a/module/research/selector.py +++ b/module/research/selector.py @@ -1,3 +1,25 @@ +""" +科研项目筛选器。 + +本模块负责从截图中检测科研项目列表,并根据用户配置的筛选规则 +对项目进行排序和过滤,输出符合优先级的候选项目列表。 + +支持两种服务器检测策略: +- JP 服务器:逐个点击项目进入详情页,通过模板匹配识别系列、 + 类型、消耗和舰船信息(因 JP 服务器无 OCR 项目名称) +- 其他服务器:通过 OCR 识别项目名称 + 模板匹配识别系列编号 + +筛选规则基于正则表达式解析用户配置的过滤器字符串, +支持按系列(S1-S9)、舰船、稀有度(DR/PRY)、类型(B/C/D/E/G/H/Q/T)、 +编号和时长进行多维度筛选,并支持 preset(预设)和 custom(自定义)两种模式。 + +术语对照: + 系列(Series): 科研系列编号 S1-S9 + 类型(Genre): 科研项目类型 B/C/D/E/G/H/Q/T + 蓝图(Blueprint): 科研产出的舰船设计图 + DR: 决战方案(Dreamship Rarity),金色稀有度科研舰船 + PRY: 近代方案(Priority Rarity),紫色稀有度科研舰船 +""" import re from functools import partial @@ -34,13 +56,33 @@ class ResearchSelector(ResearchUI): + """ + 科研项目筛选器,负责检测和筛选科研项目。 + + 从截图中识别 5 个科研项目的名称、系列和状态,然后根据用户 + 配置的筛选规则(preset 或 custom)对项目进行优先级排序。 + + JP 服务器使用逐个点击详情页的检测策略,其他服务器使用 OCR + 模板匹配。 + + Attributes: + projects (list[ResearchProject]): 当前屏幕上的 5 个科研项目列表。 + storage_has_boxes (bool): 仓库中是否有可拆解的科技箱/装备, + 影响 E 系列科研的筛选。由 StorageHandler 设置。 + """ # 当前科研项目列表 projects: list # 来自 StorageHandler storage_has_boxes = True def research_goto_detail(self, index, skip_first_screenshot=True): - logger.info(f'Research goto detail (project {index})') + """ + 点击进入指定索引的科研项目详情页。 + + Args: + index (int): 科研项目索引,0 到 4。 + skip_first_screenshot (bool): 是否跳过首次截图,复用上一状态的截图。 + """ + logger.info(f'[科研-详情] 进入科研详情 (项目 {index})') click_timer = Timer(10) while 1: if skip_first_screenshot: @@ -76,13 +118,13 @@ def _research_jp_detect(self, skip_first_screenshot=True): self.device.screenshot() if self.info_bar_count(): - logger.info('Handle info_bar') + logger.info('[科研-检测] 处理信息栏') timeout.reset() continue project = research_jp_detect(self.device.image) if project.duration == '0': - logger.warning(f'Invalid research duration: {project}') + logger.warning(f'[科研-检测] 无效的科研时长: {project}') continue else: return project @@ -108,7 +150,7 @@ def research_detect(self): 我们需要的是当前屏幕 'self.device.image'。 """ project = self._research_jp_detect() - logger.attr('Project', project) + logger.attr('科研项目', project) projects.append(project) self.research_detail_quit() """ @@ -128,13 +170,13 @@ def research_detect(self): projects = research_detect(self.device.image) if timeout.reached(): - logger.warning('Failed to OCR research name after 3 trial, assume correct') + logger.warning('[科研-检测] 尝试3次后仍无法OCR科研名称,假设正确') break if sum([p.valid for p in projects]) < 5: # 最左侧的科研系列被战令信息遮挡,参见 #1037 - logger.info('Invalid project detected') - logger.info('Probably because of battle pass info or too fast screenshot') + logger.info('[科研-检测] 检测到无效项目') + logger.info('[科研-检测] 可能是因为战令信息或截图过快') # 罕见情况,少量 sleep 可以接受 self.device.sleep(1) self.device.screenshot() @@ -146,6 +188,15 @@ def research_detect(self): def research_sort_filter(self, enforce=False): """ + 根据用户配置的筛选规则对科研项目进行优先级排序。 + + 加载预设或自定义过滤器字符串,解析后应用到当前项目列表, + 输出按优先级排序的候选项目列表。 + + Args: + enforce (bool): 是否为强制模式,强制模式下会追加默认 + 预设作为兜底筛选条件。 + Returns: list: ResearchProject 对象和预设字符串的列表, 如 [object, object, object, 'reset'] @@ -161,16 +212,16 @@ def research_sort_filter(self, enforce=False): and f'{preset}_cube' in DICT_FILTER_PRESET: preset = f'{preset}_cube' if preset not in DICT_FILTER_PRESET: - logger.warning(f'Preset not found: {preset}, use default preset') + logger.warning(f'[科研-筛选] 预设未找到: {preset},使用默认预设') preset = GeneratedConfig.Research_PresetFilter string = DICT_FILTER_PRESET[preset] - logger.attr('Research preset', preset) - logger.info('Use cube: {} Use coin: {} Use part: {}'.format( + logger.attr('科研预设', preset) + logger.info('[科研-资源] 使用魔方: {} 使用金币: {} 使用部件: {}'.format( self.config.Research_UseCube, self.config.Research_UseCoin, self.config.Research_UsePart)) - logger.attr('Allow delay', self.config.Research_AllowDelay) + logger.attr('允许延迟', self.config.Research_AllowDelay) # 不区分大小写 string = string.lower() @@ -185,16 +236,22 @@ def research_sort_filter(self, enforce=False): priority = FILTER.apply(self.projects, func=partial(self._research_check, enforce=enforce)) # 日志 - logger.attr('Filter_sort', ' > '.join([str(project) for project in priority])) + logger.attr('过滤排序', ' > '.join([str(project) for project in priority])) return priority def _research_check(self, project, enforce=False): """ + 检查单个科研项目是否符合用户的资源消耗和类型约束。 + + 根据用户配置检查魔方(coin)、金币(cube)、部件(part)的消耗限制, + 以及 B 系列、T 系列、E 系列的特殊过滤规则。 + Args: - project (ResearchProject): - enforce (Bool): + project (ResearchProject): 待检查的科研项目。 + enforce (bool): 是否为强制模式,强制模式下忽略部分资源限制。 + Returns: - bool: + bool: 项目是否通过所有检查条件。 """ if not project.valid: return False @@ -252,6 +309,13 @@ def _research_check(self, project, enforce=False): def research_sort_shortest(self, enforce): """ + 按最短时长优先排序科研项目。 + + 当筛选结果为空时,优先选择时长最短的项目以快速获取科研收益。 + + Args: + enforce (bool): 是否为强制模式。 + Returns: list: ResearchProject 对象和预设字符串的列表, 如 [object, object, object, 'reset'] @@ -259,11 +323,18 @@ def research_sort_shortest(self, enforce): FILTER.load(FILTER_STRING_SHORTEST) priority = FILTER.apply(self.projects, func=partial(self._research_check, enforce=enforce)) - logger.attr('Filter_sort', ' > '.join([str(project) for project in priority])) + logger.attr('过滤排序', ' > '.join([str(project) for project in priority])) return priority def research_sort_cheapest(self, enforce): """ + 按最低消耗优先排序科研项目。 + + 当筛选结果为空时,优先选择消耗最少资源的项目以节省资源。 + + Args: + enforce (bool): 是否为强制模式。 + Returns: list: ResearchProject 对象和预设字符串的列表, 如 [object, object, object, 'reset'] @@ -271,5 +342,5 @@ def research_sort_cheapest(self, enforce): FILTER.load(FILTER_STRING_CHEAPEST) priority = FILTER.apply(self.projects, func=partial(self._research_check, enforce=enforce)) - logger.attr('Filter_sort', ' > '.join([str(project) for project in priority])) + logger.attr('过滤排序', ' > '.join([str(project) for project in priority])) return priority diff --git a/module/research/series.py b/module/research/series.py index 89e534c45..670b1d1e2 100644 --- a/module/research/series.py +++ b/module/research/series.py @@ -1,3 +1,20 @@ +""" +科研系列编号识别。 + +本模块通过模板匹配从截图中识别科研项目的系列编号(S1-S9)。 + +科研系列编号以罗马数字形式显示在项目卡片上,由于透视效果, +不同位置的项目卡片存在缩放差异,因此使用 RESEARCH_SCALING +对各位置的模板匹配进行缩放补偿。 + +主要功能: +- 列表中的系列识别:通过裁剪各项目卡片的系列标识区域, + 使用灰度图模板匹配逐个识别 5 个项目的系列编号 +- 详情页的系列识别:从科研详情页中识别系列编号 + +术语对照: + 系列(Series): 科研系列编号 S1-S9,对应不同的科研舰船池 +""" from module.base.utils import area_pad, crop, rgb2gray from module.research.assets import * @@ -12,6 +29,19 @@ def match_series(image, scaling): + """ + 通过模板匹配识别单个科研项目的系列编号。 + + 按 S9 -> S1 的顺序依次匹配模板,优先匹配高编号系列 + 以避免低编号模板的误匹配。 + + Args: + image (np.ndarray): 裁剪后的系列标识区域灰度图像。 + scaling (float): 模板匹配的缩放因子,用于补偿透视效果。 + + Returns: + int: 系列编号(1-9),匹配失败返回 0。 + """ image = rgb2gray(image) if TEMPLATE_S9.match(image, scaling=scaling): @@ -39,12 +69,18 @@ def match_series(image, scaling): def get_research_series_3(image, series_button=RESEARCH_SERIES): """ + 从科研列表截图中批量识别 5 个项目的系列编号。 + + 通过裁剪各项目卡片的系列标识区域,使用带缩放补偿的 + 模板匹配逐一识别系列编号。不同位置的项目因透视效果 + 需要不同的缩放因子(由 RESEARCH_SCALING 定义)。 + Args: - image: - series_button (list[Button]): + image (np.ndarray): 科研列表页面的完整截图。 + series_button (list[Button]): 5 个系列标识区域的按钮定义。 Returns: - list[int]: + list[int]: 5 个项目的系列编号列表,如 [1, 3, 5, 4, 2]。 """ return [ match_series(crop(image, area_pad(button.area, pad=-10), copy=False), scaling) @@ -54,10 +90,15 @@ def get_research_series_3(image, series_button=RESEARCH_SERIES): def get_detail_series(image): """ + 从科研详情页截图中识别系列编号。 + + 裁剪详情页的系列标识区域,使用模板匹配(缩放因子 1.0) + 识别当前详情页显示的项目所属系列。 + Args: - image: + image (np.ndarray): 科研详情页的截图。 Returns: - int: + int: 系列编号(1-9),匹配失败返回 0。 """ return match_series(crop(image, area_pad(SERIES_DETAIL.area, pad=-30), copy=False), scaling=1.0) diff --git a/module/research/ui.py b/module/research/ui.py index 3f652c11f..7820b6796 100644 --- a/module/research/ui.py +++ b/module/research/ui.py @@ -1,3 +1,22 @@ +""" +科研系统 UI 操作基类。 + +本模块提供科研系统的底层 UI 操作,包括: +- 页面检测:判断当前是否在科研主页或队列页面 +- 页面稳定性等待:等待科研卡片动画完成 +- 队列页面的进入和退出导航 +- 奖励物品的获取和掉落记录 +- 科研项目状态检测:通过模板匹配识别 waiting/running/detail 状态 +- 科研详情页的退出和取消操作 + +本模块作为 ResearchSelector、ResearchQueue 和 RewardResearch +的共同基类,提供统一的 UI 操作接口。 + +术语对照: + 科研队列(Research Queue): 科研页面中可容纳 5 个排队项目的区域 + 详情页(Detail): 点击科研项目后展开的详细信息页面 + 获取物品界面(Get Items): 领取科研奖励后弹出的物品展示界面 +""" from module.base.timer import Timer from module.base.utils import crop, rgb2gray from module.combat.assets import GET_ITEMS_1, GET_ITEMS_2, GET_ITEMS_3, GET_ITEMS_3_CHECK @@ -10,16 +29,54 @@ class ResearchUI(UI): + """ + 科研系统 UI 操作基类,提供科研页面的底层交互方法。 + + 所有科研相关的 UI 操作(页面检测、稳定性等待、队列导航、 + 奖励领取、状态检测等)封装在此类中,供上层模块组合使用。 + + 继承自 UI 基类,获得页面导航和通用 UI 操作能力。 + """ def is_in_research(self, interval=0): + """ + 检测当前是否在科研主页(项目列表页面)。 + + Args: + interval (int): 按钮检测的时间间隔,0 表示每次都检测。 + + Returns: + bool: 是否在科研主页。 + """ return self.appear(RESEARCH_CHECK, offset=(20, 20), interval=interval) def is_in_queue(self, interval=0): + """ + 检测当前是否在科研队列页面。 + + Args: + interval (int): 按钮检测的时间间隔,0 表示每次都检测。 + + Returns: + bool: 是否在科研队列页面。 + """ return self.appear(QUEUE_CHECK, offset=(20, 20), interval=interval) def ensure_research_stable(self): + """ + 等待科研项目列表页面的动画稳定。 + + 确保科研卡片的切换/加载动画完成后才进行后续操作, + 避免因动画未完成导致的误检测。 + """ self.wait_until_stable(STABLE_CHECKER) def ensure_research_center_stable(self): + """ + 等待科研项目列表中心区域的动画稳定。 + + 与 ensure_research_stable 类似,但使用中心区域的检测器, + 适用于从队列页面返回等场景。 + """ self.wait_until_stable(STABLE_CHECKER_CENTER) def queue_enter(self, skip_first_screenshot=True): @@ -37,7 +94,7 @@ def queue_quit(self): in: is_in_queue out: is_in_research, project stabled """ - logger.info('Queue quit') + logger.info('[科研-队列] 退出队列') for _ in self.loop(): if self.is_in_research(): break @@ -47,11 +104,11 @@ def queue_quit(self): # handle get_items # get_items should be handled when receiving, but sometimes just slow network if self.appear(GET_ITEMS_1, offset=(20, 20), interval=3): - logger.info(f'{GET_ITEMS_1} -> {GET_ITEMS_RESEARCH_SAVE}') + logger.info(f'[科研-队列] {GET_ITEMS_1} -> {GET_ITEMS_RESEARCH_SAVE}') self.device.click(GET_ITEMS_RESEARCH_SAVE) continue if self.appear(GET_ITEMS_2, offset=(20, 20), interval=3): - logger.info(f'{GET_ITEMS_1} -> {GET_ITEMS_RESEARCH_SAVE}') + logger.info(f'[科研-队列] {GET_ITEMS_1} -> {GET_ITEMS_RESEARCH_SAVE}') self.device.click(GET_ITEMS_RESEARCH_SAVE) continue @@ -112,14 +169,31 @@ def get_research_status(self, image): else: out.append('unknown') - logger.info(f'Research status: {out}') + logger.info(f'[科研-状态] 科研状态: {out}') return out def is_research_stabled(self): + """ + 检测科研主页是否已稳定(无动画进行中)。 + + 通过检测是否存在 'detail' 状态的项目来判断页面是否已加载完成。 + + Returns: + bool: 科研主页是否已稳定。 + """ return self.is_in_research() and 'detail' in self.get_research_status(self.device.image) def research_detail_quit(self, skip_first_screenshot=True): - logger.info('Research detail quit') + """ + 从科研详情页退回科研主页。 + + 点击详情页的退出按钮,等待回到稳定的项目列表页面。 + 不取消正在进行的科研项目。 + + Args: + skip_first_screenshot (bool): 是否跳过首次截图。 + """ + logger.info('[科研-详情] 退出科研详情') click_timer = Timer(10) while 1: if skip_first_screenshot: @@ -138,7 +212,16 @@ def research_detail_quit(self, skip_first_screenshot=True): click_timer.reset() def research_detail_cancel(self, skip_first_screenshot=True): - logger.info('Research detail cancel') + """ + 取消正在进行的科研项目并退回科研主页。 + + 点击停止按钮取消当前项目,确认弹窗后等待回到稳定的项目列表页面。 + 与 research_detail_quit 不同,此方法会取消正在运行的科研。 + + Args: + skip_first_screenshot (bool): 是否跳过首次截图。 + """ + logger.info('[科研-详情] 取消科研项目') while 1: if skip_first_screenshot: skip_first_screenshot = False diff --git a/module/retire/assets.py b/module/retire/assets.py index 4b226ccce..8ed305022 100644 --- a/module/retire/assets.py +++ b/module/retire/assets.py @@ -1,3 +1,5 @@ +"""退役模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/retire/dock.py b/module/retire/dock.py index 6b3ed56c5..d1cf72836 100644 --- a/module/retire/dock.py +++ b/module/retire/dock.py @@ -1,3 +1,12 @@ +""" +船坞界面操作模块。 + +提供船坞页面的 UI 交互功能,包括卡片网格布局、排序切换、 +收藏筛选、筛选器设置与确认、舰船选择、进入舰船详情等操作。 +定义了卡片各属性区域 (稀有度、等级、情绪、情绪状态) 的 +按钮网格和情绪颜色常量。 +""" + import module.config.server as server from module.base.button import ButtonGrid, get_color, color_similar @@ -40,6 +49,15 @@ class Dock(Equipment): + """船坞界面操作处理器。 + + 提供船坞页面的通用操作:卡片加载等待、排序切换、收藏筛选、 + 筛选器设置、舰船选择与确认、进入舰船详情等。 + 继承 Equipment 以复用装备侧边导航栏操作。 + + 服务器差异:TW 服务器的筛选器按钮布局与其他服务器不同, + 通过 @Config.when 装饰器分发。 + """ def handle_dock_cards_loading(self, skip_first_screenshot=True): """ 等待船坞卡片加载完成。 @@ -67,7 +85,7 @@ def handle_dock_cards_loading(self, skip_first_screenshot=True): new_result = scanner.scan(self.device.image) if self.appear(DOCK_EMPTY): - logger.info('Dock empty') + logger.info('船坞为空') break if timeout.reached(): break @@ -101,7 +119,7 @@ def dock_sort_method_dsc_set(self, enable=True, wait_loading=True): self.handle_dock_cards_loading() def dock_filter_enter(self): - logger.info('Dock filter enter') + logger.info('船坞筛选进入') self.interval_clear(DOCK_CHECK) for _ in self.loop(): if self.appear(DOCK_FILTER_CONFIRM, offset=(20, 60)): @@ -338,7 +356,7 @@ def dock_selected(self, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning('Get dock_selected timeout, assume not selected') + logger.warning('[退役-船坞] 获取已选数量超时,假设未选中') break current, _, total = OCR_DOCK_SELECTED.ocr(self.device.image) @@ -384,7 +402,7 @@ def dock_enter_first(self, non_npc=True, skip_first_screenshot=True): in: page_dock out: SHIP_DETAIL_CHECK """ - logger.info('Dock enter first') + logger.info('进入船坞首选') self.interval_clear(DOCK_CHECK, interval=3) while 1: @@ -397,7 +415,7 @@ def dock_enter_first(self, non_npc=True, skip_first_screenshot=True): if self.appear(SHIP_DETAIL_CHECK, offset=(20, 20)): return True if self.appear(DOCK_EMPTY, offset=(20, 20)): - logger.info('Dock empty') + logger.info('船坞为空') return False # Click @@ -405,12 +423,12 @@ def dock_enter_first(self, non_npc=True, skip_first_screenshot=True): if non_npc: # Check NPC if DOCK_FIRST_NPC.match_luma(self.device.image, offset=(20, 20)): - logger.info('First ship is NPC, select second') + logger.info('第一艘是NPC舰船,选择第二艘') button = CARD_GRIDS[(1, 0)] # Check if there's second ship color = get_color(self.device.image, button.area) if color_similar(color, (34, 34, 42)): - logger.info('Second ship empty, dock empty') + logger.info('第二艘为空,船坞为空') return False else: button = CARD_GRIDS[(0, 0)] diff --git a/module/retire/enhancement.py b/module/retire/enhancement.py index 72746fad3..e84a30c1a 100644 --- a/module/retire/enhancement.py +++ b/module/retire/enhancement.py @@ -1,3 +1,14 @@ +""" +舰船强化系统。 + +提供船坞中舰船装备强化的完整流程,包括按舰船类型分类强化、 +推荐材料选择、强化确认、失败重试等操作。通过 DFA(确定性有限自动机) +状态机模式驱动强化流程,自动处理战斗中、无材料等异常情况。 + +当退役模式为 enhance 时,优先执行强化;强化完成或失败后 +可回退到退役流程。 +""" + from random import choice import module.config.server as server @@ -19,6 +30,15 @@ class Enhancement(Dock): + """舰船强化处理器。 + + 继承 Dock 类,在船坞页面中执行装备强化操作。 + 使用 DFA 状态机模式管理强化流程,支持按舰船类型 + 分类处理、推荐材料自动选择、强化失败后的自动翻页重试。 + + Attributes: + _retire_amount (property): 根据退役模式配置计算的退役数量上限。 + """ @property def _retire_amount(self): if self.config.Retirement_RetireMode == 'one_click_retire': @@ -128,7 +148,7 @@ def state_enhance_check(): need_to_skip = False if ship_count <= 0: logger.info( - 'Reached maximum number to check, exiting current category') + '[退役-强化] 已达到最大检查次数,退出当前分类') return "state_enhance_exit" if not self.equip_side_navbar_ensure(bottom=4): return "state_enhance_check" @@ -140,7 +160,7 @@ def state_enhance_check(): def state_enhance_ready(): # Wait until ENHANCE_RECOMMEND appears if self.appear_then_click(ENHANCE_RECOMMEND, offset=(5, 5), interval=0.3): - logger.info('Set enhancement material by recommendation.') + logger.info('按推荐设置强化材料') return "state_enhance_recommend" return "state_enhance_ready" @@ -148,12 +168,12 @@ def state_enhance_ready(): def state_enhance_recommend(): # Judge if enhance material appeared if not EMPTY_ENHANCE_SLOT_PLUS.match(self.device.image): - logger.info('Material found. Try enhancing...') + logger.info('找到材料,尝试强化...') return "state_enhance_attempt" elif self.info_bar_count(): - logger.info('No material found for enhancement.') + logger.info('未找到强化材料') logger.info( - 'Enhancement failed. Swiping to next ship if feasible') + '[退役-强化] 强化失败,滑动到下一艘舰船(如可行)') return "state_enhance_fail" return "state_enhance_ready" @@ -171,17 +191,17 @@ def state_enhance_attempt(): def state_enhance_confirm(): # Succeeded if EQUIP_CONFIRM appeared, otherwise failed if self.appear(EQUIP_CONFIRM, offset=(30, 30)): - logger.info('Enhancement Successful') + logger.info('强化成功') self._enhance_confirm() return "state_enhance_success" elif self.info_bar_count(): logger.info( - 'Enhancement impossible, ship currently in battle. Swiping to next ship if feasible') + '[退役-强化] 强化不可行,舰船当前在战斗中,滑动到下一艘舰船(如可行)') nonlocal need_to_skip need_to_skip = True return "state_enhance_fail" elif self.handle_popup_confirm('ENHANCE'): - logger.info('Trying a temporary ship') + logger.info('尝试临时舰船') return "state_enhance_confirm" return "state_enhance_attempt" @@ -202,7 +222,7 @@ def state_enhance_fail(): if self.appear(EQUIP_CONFIRM, offset=(30, 30)): return "state_enhance_confirm" else: - logger.info('Swiped failed, exiting current category') + logger.info('滑动失败,退出当前分类') return "state_enhance_exit" def state_enhance_success(): @@ -218,7 +238,7 @@ def state_enhance_exit(): skip_first_screenshot = False else: self.device.screenshot() - logger.info(f'Call state function: {state}') + logger.info(f'调用状态函数: {state}') if state == "state_enhance_check": # Avoid too_many_click exception caused by multiple tries without material @@ -238,8 +258,8 @@ def state_enhance_exit(): try: state = locals()[state]() except KeyError as e: - logger.warning(f'Unknown state function: {state}') - raise ScriptError(f'Unknown state function: {state}') + logger.warning(f'未知的状态函数: {state}') + raise ScriptError(f'未知的状态函数: {state}') return state, ship_count @@ -264,7 +284,7 @@ def enhance_ships(self, favourite=None): if favourite is None: favourite = self.config.Enhance_ShipToEnhance == 'favourite' - logger.hr('Enhancement by type') + logger.hr('按类型强化') total = 0 # Process ENHANCE_ORDER_STRING if any into ship_types @@ -276,7 +296,7 @@ def enhance_ships(self, favourite=None): ship_types = [None] else: ship_types = [None] - logger.attr('Enhance Order', ship_types) + logger.attr('强化顺序', ship_types) # Process available ship types for choice randomization # Removing types that have already been specified by @@ -292,17 +312,17 @@ def enhance_ships(self, favourite=None): if ship_type is not None and ship_type not in VALID_SHIP_TYPES: if len(available_ship_types) == 0: logger.info( - 'No more ship types for AzurPilot to choose from, skipping iteration') + '[退役-强化] 无可选舰船类型,跳过本次迭代') continue ship_type = choice(available_ship_types) available_ship_types.remove(ship_type) - logger.info(f'Favourite={favourite}, Ship Type={ship_type}') + logger.info(f'收藏={favourite}, 舰船类型={ship_type}') # Continue if at least 1 CARD_GRID is selectable # otherwise skip to next ship type if not self._enhance_enter(favourite=favourite, ship_type=ship_type): - logger.hr(f'Dock Empty by ship type {ship_type}') + logger.hr(f'[退役-强化] 船坞为空,舰船类型: {ship_type}') continue current_count = self.config.Enhance_CheckPerCategory diff --git a/module/retire/retirement.py b/module/retire/retirement.py index 1ea42c394..ba7555148 100644 --- a/module/retire/retirement.py +++ b/module/retire/retirement.py @@ -1,3 +1,11 @@ +""" +退役系统主处理器。 + +提供舰船退役的完整流程,包括一键退役和旧退役两种模式。 +支持按稀有度筛选、退役确认、装备拆解、普通航母保留等操作。 +当船坞满载时,根据配置自动选择退役或强化策略。 +""" + import re from module.base.button import Button, ButtonGrid @@ -65,6 +73,20 @@ } class Retirement(Enhancement, QuickRetireSettingHandler): + """退役系统主处理器。 + + 组合强化处理器 (Enhancement) 和快速退役设置处理器 + (QuickRetireSettingHandler),提供一键退役和旧退役两种模式的 + 完整退役流程。 + + 负责退役确认弹窗处理、装备拆解确认、普通航母保留策略, + 以及船坞满载时退役与强化的自动切换。 + + Attributes: + _unable_to_enhance (bool): 标记当前是否无法强化,切换到退役流程。 + _have_kept_cv (bool): 标记是否已保留一艘普通航母。 + map_cat_attack_timer (Timer): 用于战斗中退役弹窗的计时。 + """ _unable_to_enhance = False _have_kept_cv = True @@ -101,11 +123,10 @@ def _retirement_choose(self, amount=10, target_rarity=('N',)): f = True if not f: - logger.warning( - f'Unknown rarity color. Grid: ({x}, {y}). Color: {card_color}') + logger.warning(f'[退役-稀有度] 未知稀有度颜色, 网格: ({x}, {y}), 颜色: {card_color}') - logger.info(' '.join([r.rjust(3) for r in rarity[:7]])) - logger.info(' '.join([r.rjust(3) for r in rarity[7:]])) + logger.info('[退役-稀有度] ' + ' '.join([r.rjust(3) for r in rarity[:7]])) + logger.info('[退役-稀有度] ' + ' '.join([r.rjust(3) for r in rarity[7:]])) selected = 0 for card, r in zip(cards, rarity): @@ -131,7 +152,7 @@ def _retirement_confirm(self, skip_first_screenshot=True): SHIP_CONFIRM(旧退役模式) out: IN_RETIREMENT_CHECK """ - logger.info('Retirement confirm') + logger.info('[退役-确认] 退役确认') executed = False for button in [SHIP_CONFIRM, SHIP_CONFIRM_2, EQUIP_CONFIRM, EQUIP_CONFIRM_2, GET_ITEMS_1, SR_SSR_CONFIRM]: self.interval_clear(button) @@ -145,7 +166,7 @@ def _retirement_confirm(self, skip_first_screenshot=True): # 结束条件——超时兜底 if timeout.reached(): - logger.warning('Wait _retirement_confirm timeout, assume finished') + logger.warning('[退役-确认] 等待退役确认超时,假设已完成') break # 有时 EQUIP_CONFIRM 没有黑色模糊背景,与 IN_RETIREMENT_CHECK 同时出现 if self.appear(IN_RETIREMENT_CHECK, offset=(20, 20)) and not self.appear(EQUIP_CONFIRM, offset=(30, 30)): @@ -253,7 +274,7 @@ def _retire_wait_slow_retire(self, skip_first_screenshot=True): Returns: bool: SHIP_CONFIRM_2 出现则返回 True。 """ - logger.info('Wait slow retire') + logger.info('[退役-确认] 等待慢速退役') self.device.click_record_clear() self.device.stuck_record_clear() while 1: @@ -277,8 +298,8 @@ def retire_ships_one_click(self): Returns: int: 退役的舰船数量(每轮 10 艘)。 """ - logger.hr('Retirement') - logger.info('Using one click retirement.') + logger.hr('退役') + logger.info('[退役-一键] 使用一键退役') # 一键退役不需要等待加载船坞 self.dock_favourite_set(wait_loading=False) self.dock_sort_method_dsc_set(wait_loading=False) @@ -303,13 +324,13 @@ def retire_ships_one_click(self): if self.appear(SHIP_CONFIRM_2, offset=(30, 30)): break if self.info_bar_count(): - logger.info('No more ships to retire.') + logger.info('[退役-一键] 没有更多舰船可退役') end = True break # 点击——多次重试后等待慢速退役 if click_count >= 5: - logger.warning('Failed to select ships using ONE_CLICK_RETIREMENT after 5 trial') + logger.warning('[退役-一键] 尝试5次后仍无法选择舰船') if self._retire_wait_slow_retire(): # 等待成功,继续在同一截图上触发 ONE_CLICK_RETIREMENT pass @@ -331,7 +352,7 @@ def retire_ships_one_click(self): # 客户端一次性退役所有舰船,直接退出 break - logger.info(f'Total retired round: {total // 10}') + logger.info(f'[退役-一键] 退役总轮数: {total // 10}') return total def retire_ships_old(self, amount=None, rarity=None): @@ -351,8 +372,8 @@ def retire_ships_old(self, amount=None, rarity=None): amount = self._retire_amount if rarity is None: rarity = self._retire_rarity - logger.hr('Retirement') - logger.info(f'Amount={amount}. Rarity={rarity}') + logger.hr('退役') + logger.info(f'[退役-旧] 数量={amount}, 稀有度={rarity}') # 将稀有度映射为过滤器名称 correspond_name = { @@ -380,7 +401,7 @@ def retire_ships_old(self, amount=None, rarity=None): break self.device.screenshot() if not self.match_template_color(SHIP_CONFIRM, offset=(30, 30)): - logger.warning('No ship selected, retrying') + logger.warning('[退役-旧] 未选中舰船,重试中') continue self._retirement_confirm() @@ -394,7 +415,7 @@ def retire_ships_old(self, amount=None, rarity=None): self.dock_sort_method_dsc_set(True, wait_loading=False) self.dock_filter_set() - logger.info(f'Total retired: {total}') + logger.info(f'[退役-旧] 退役总数: {total}') return total def retire_gems_farming_flagships(self, keep_one=True) -> int: @@ -410,11 +431,11 @@ def retire_gems_farming_flagships(self, keep_one=True) -> int: Returns: int: 退役的舰船数量。 """ - logger.info('Retire abandoned flagships of GemsFarming/ThreeOilLowCost') + logger.info('[退役-保留] 退役钻石打捞/三油低耗的废弃旗舰') gems_farming_enable: bool = self.config.is_task_enabled('GemsFarming') or self.config.is_task_enabled('ThreeOilLowCost') if not gems_farming_enable: - logger.info('Not in GemsFarming/ThreeOilLowCost, skip') + logger.info('[退役-保留] 非钻石打捞/三油低耗任务,跳过') return 0 self.dock_favourite_set(wait_loading=False) @@ -495,7 +516,7 @@ def handle_retirement(self): self.map_cat_attack_timer.reset() return True except Exception as e: - logger.warning(f'Retirement failed: {e}') + logger.warning(f'[退役-船坞] 退役失败: {e}') self._unable_to_enhance = False # 防止无限循环 return False elif self.config.Retirement_RetireMode == 'enhance': @@ -509,15 +530,14 @@ def handle_retirement(self): try: total, remain = self._enhance_handler() if not total: - logger.info( - 'No ship to enhance, but dock full, will try retire') + logger.info('[退役-船坞] 无舰船可强化,但船坞已满,将尝试退役') self._unable_to_enhance = True - logger.info(f'The remaining spare dock amount is {remain}') + logger.info(f'[退役-船坞] 剩余空闲船坞数量: {remain}') if remain < 3: - logger.info('Too few spare docks, retire next time') + logger.info('[退役-船坞] 空闲船坞过少,下次再退役') self._unable_to_enhance = True except Exception as e: - logger.warning(f'Enhancement failed: {e}') + logger.warning(f'[退役-船坞] 强化失败: {e}') self._unable_to_enhance = True # 尝试退役 self.interval_reset(DOCK_CHECK) self.map_cat_attack_timer.reset() @@ -536,7 +556,7 @@ def handle_retirement(self): self.map_cat_attack_timer.reset() return True except Exception as e: - logger.warning(f'Retirement failed: {e}') + logger.warning(f'[退役-船坞] 退役失败: {e}') self._unable_to_enhance = False # 防止无限循环 return False @@ -567,29 +587,29 @@ def _retire_handler(self, mode=None): # 当模式为 'enhance' 时,使用 'one_click_retire' 作为默认退役模式 if mode == 'enhance': - logger.info('Retirement mode is set to enhance, using one_click_retire as fallback') + logger.info('[退役-船坞] 退役模式设为强化,使用一键退役作为后备') mode = 'one_click_retire' if mode == 'one_click_retire': total = self.retire_ships_one_click() if not total: logger.warning( - 'No ship retired, trying to reset dock filter and disable favourite, then retire again') + '[退役-船坞] 未退役舰船,尝试重置船坞筛选器并禁用收藏,然后再次退役') self.dock_favourite_set(False, wait_loading=False) self.dock_filter_set() total = self.retire_ships_one_click() if self.server_support_quick_retire_setting_fallback(): # 部分用户可能已设置 filter_5='all',先尝试保留该设置 if not total: - logger.warning('No ship retired, trying to reset the first 4 quick retire settings') + logger.warning('[退役-船坞] 未退役舰船,尝试重置前4个快速退役设置') self.quick_retire_setting_set(filter_5=None) total = self.retire_ships_one_click() if not total: - logger.warning('No ship retired, trying to reset quick retire settings to "keep_limit_break"') + logger.warning('[退役-船坞] 未退役舰船,尝试重置快速退役设置为"保留突破"') self.quick_retire_setting_set(filter_5='keep_limit_break') total = self.retire_ships_one_click() if not total and self.config.OneClickRetire_KeepLimitBreak == 'do_not_keep': - logger.warning('No ship retired, trying to reset quick retire settings to "all"') + logger.warning('[退役-船坞] 未退役舰船,尝试重置快速退役设置为"全部"') self.quick_retire_setting_set('all') total = self.retire_ships_one_click() total += self.retire_gems_farming_flagships(keep_one=total > 0) @@ -609,7 +629,7 @@ def _retire_handler(self, mode=None): raise RequestHumanTakeover else: raise ScriptError( - f'Unknown retire mode: {self.config.Retirement_RetireMode}') + f'[退役-模式] 未知退役模式: {self.config.Retirement_RetireMode}') self._retirement_quit() self.config.DOCK_FULL_TRIGGERED = True @@ -642,7 +662,7 @@ def _retire_select_one(self, button, skip_first_screenshot=True): if not RETIRE_COIN.match(self.device.image, offset=(20, 20), similarity=0.97): return True if count > 3: - logger.warning('_retire_select_one failed after 3 trial') + logger.warning('[退役-选择] 尝试3次后仍无法选择舰船') return False if self.appear(SHIP_CONFIRM_2, offset=(30, 30), interval=2): @@ -665,7 +685,7 @@ def get_common_ship_filter(self, string, ship_type='cv', output=True): list[str]: 去重后的舰船名称列表,如 ['bogue', 'hermes', 'ranger']。 """ if ship_type.lower() not in ['cv', 'dd']: - logger.warning(f'Invalid ship_type: {ship_type}') + logger.warning(f'[退役-扫描] 无效的舰船类型: {ship_type}') return [] ship_type = ship_type.upper() @@ -680,14 +700,14 @@ def get_common_ship_filter(self, string, ship_type='cv', output=True): common_cv = list(dict.fromkeys( [str(name[0]) for name in filter_obj.filter if name[0].upper() in templates])) if not common_cv: - logger.warning(f'Invalid filter set: "{string}". Set to default filter.') + logger.warning(f'[退役-扫描] 无效的筛选器: "{string}",使用默认筛选器') string = default self.config.cross_set(keys=key, value=default) continue # 结束条件——过滤器解析成功 if output: - logger.attr('Filter sort', ' > '.join(common_cv)) + logger.attr('筛选器排序', ' > '.join(common_cv)) return common_cv def retirement_get_common_rarity_cv_in_page(self): @@ -706,7 +726,7 @@ def retirement_get_common_rarity_cv_in_page(self): common_cv = self.get_common_ship_filter(filter_string, ship_type='cv', output=False) if self.config.GemsFarming_CommonCV == 'eagle' and 'hermes' in common_cv: common_cv.remove('hermes') - logger.attr('Filter sort', ' > '.join(common_cv)) + logger.attr('筛选器排序', ' > '.join(common_cv)) for name in common_cv: template = globals()[f'TEMPLATE_{name.upper()}'] sim, button = template.match_result( @@ -764,23 +784,23 @@ def retirement_get_common_rarity_cv(self, skip_first_screenshot=False): else: disappear_confirm.start() if disappear_confirm.reached(): - logger.warning('Scroll bar disappeared, stop') + logger.warning('滚动条消失,停止') break else: continue if not top_checked: top_checked = True - logger.info('Find common CV from bottom to top') + logger.info('从下到上查找通用航母') RETIRE_CONFIRM_SCROLL.set_bottom(main=self) continue else: if RETIRE_CONFIRM_SCROLL.at_top(main=self): - logger.info('Scroll bar reached top, stop') + logger.info('[退役-滚动] 滚动条已到达顶部,停止') break # 向上翻页 if swipe_count >= 7: - logger.info('Reached maximum swipes to find common CV') + logger.info('[退役-滚动] 已达到最大滑动次数查找普通航母') break RETIRE_CONFIRM_SCROLL.prev_page(main=self) swipe_count += 1 @@ -793,9 +813,9 @@ def keep_one_common_cv(self): 通过滚动查找并选中一艘普通航母,避免 GemsFarming 全部退役。 """ - logger.info('Keep one common CV') + logger.info('保留一艘通用航母') button = self.retirement_get_common_rarity_cv() if button is not None: self._retire_select_one(button) self._have_kept_cv = True - logger.info('Keep one common CV end') + logger.info('保留一艘通用航母完成') diff --git a/module/retire/scanner.py b/module/retire/scanner.py index 641c23e05..9b86a65a7 100644 --- a/module/retire/scanner.py +++ b/module/retire/scanner.py @@ -1,3 +1,16 @@ +""" +船坞舰船扫描系统。 + +提供船坞页面中舰船属性的多维度扫描能力,包括等级、情绪、稀有度、 +舰队归属和状态识别。通过组合多个子扫描器 (LevelScanner、 +EmotionScanner、RarityScanner、FleetScanner、StatusScanner) +实现舰船信息的批量采集。 + +支持单页扫描 (ShipScanner) 和跨页滚动扫描 (DockScanner), +后者通过灰度图标准差定位卡片间隙,自动滚动并去重,完成全船坞扫描。 +DHash 感知哈希用于跨页去重判断。 +""" + import os import time from abc import ABCMeta, abstractmethod @@ -29,6 +42,11 @@ class EmotionDigit(Digit): + """情绪值 OCR 识别器,针对船坞卡片的情绪数字区域优化。 + + 针对 JP 服务器特殊处理白色文字提取, + 并修正唐斯头发区域的随机误识别 (044 -> 0)。 + """ def pre_process(self, image): if server.server == 'jp': image_gray = extract_letters(image, letter=(255, 255, 255), threshold=self.threshold) @@ -97,6 +115,15 @@ def satisfy_limitation(self, limitation) -> bool: class DHash: + """感知哈希 (Difference Hash) 实现,用于图像去重。 + + 通过比较相邻像素生成哈希值,以汉明距离判断两张图像是否相似。 + 用于 DockScanner 跨页扫描时的去重判断。 + + Attributes: + EQ_THRES (int): 哈希相等的距离阈值,默认 30。 + code (str): 生成的十六进制哈希字符串。 + """ EQ_THRES: int = 30 def __init__(self, image, size=8) -> None: @@ -131,6 +158,17 @@ def __repr__(self) -> str: class Scanner(metaclass=ABCMeta): + """扫描器抽象基类。 + + 定义船坞卡片属性扫描的通用接口,子扫描器 (LevelScanner、 + RarityScanner 等) 继承此类并实现 _scan() 方法。 + + Attributes: + _results (List): 缓存的扫描结果。 + _enabled (bool): 扫描器是否启用,禁用时返回全 None 列表。 + _disabled_value (List[None]): 禁用时的默认返回值。 + grids (ButtonGrid): 卡片属性区域的按钮网格。 + """ _results: List = None _enabled: bool = True _disabled_value: List[None] = [None] * 14 @@ -189,6 +227,7 @@ def disable(self) -> None: class LevelScanner(Scanner): + """等级扫描器,通过 OCR 识别船坞卡片上显示的舰船等级。""" def __init__(self) -> None: super().__init__() self._results = [] @@ -208,6 +247,11 @@ def move(self, vector) -> None: class EmotionScanner(Scanner): + """情绪扫描器,通过 OCR 识别舰船情绪值。 + + 结合 EmotionStatusScanner 的颜色状态进行交叉校正, + 修正 OCR 在低情绪场景下的误识别。 + """ def __init__(self) -> None: super().__init__() self._results = [] @@ -235,7 +279,7 @@ def _scan(self, image) -> List: if emotion > 40: emotion //= 10 results.append(emotion) - logger.attr('DOCK_EMOTION_OCR', results) + logger.attr('船坞情绪OCR', results) return results def limit_value(self, value) -> int: @@ -247,6 +291,11 @@ def move(self, vector) -> None: class EmotionStatusScanner(Scanner): + """情绪状态扫描器,通过颜色识别情绪指示灯。 + + 检测船坞卡片右上角指示灯的颜色:红、黄、绿,分别对应 + 不同的情绪区间。结果用于 EmotionScanner 的交叉校正。 + """ def __init__(self) -> None: super().__init__() self._results = [] @@ -280,7 +329,7 @@ def get_emotion_status(self, image) -> str: def _scan(self, image) -> List: results = [self.get_emotion_status(crop(image, button.area, copy=False)) for button in self.grids.buttons] - logger.attr('DOCK_EMOTION_STATUS', results) + logger.attr('船坞情绪状态', results) return results def limit_value(self, value) -> str: @@ -288,6 +337,11 @@ def limit_value(self, value) -> str: class RarityScanner(Scanner): + """稀有度扫描器,通过卡片顶部颜色条判断舰船稀有度。 + + 稀有度映射:common(灰)、rare(蓝)、elite(紫)、super_rare(金)。 + 彩虹稀有度因颜色差异过大标记为 unknown。 + """ def __init__(self) -> None: super().__init__() self._results = [] @@ -327,6 +381,11 @@ def limit_value(self, value) -> str: class FleetScanner(Scanner): + """舰队归属扫描器,通过模板匹配识别舰船所属的舰队编号。 + + 对卡片左下角的舰队标识进行灰度二值化预处理后, + 逐一匹配 Fleet 1-6 的模板图像。未匹配到则返回 0(不在编队)。 + """ def __init__(self) -> None: super().__init__() self._results = [] @@ -382,6 +441,11 @@ def limit_value(self, value) -> int: class StatusScanner(Scanner): + """状态扫描器,通过模板匹配识别舰船的使用状态。 + + 状态类型:free(空闲)、battle(出击中)、commission(委托中)、 + in_hard_fleet(困难舰队)、in_event_fleet(活动舰队)。 + """ def __init__(self) -> None: super().__init__() self._results = [] @@ -417,6 +481,10 @@ def limit_value(self, value) -> str: class HashGenerator(Scanner): + """哈希生成器,为每张船坞卡片生成 DHash 感知哈希。 + + 用于 DockScanner 跨页扫描时的去重判断和加载完成检测。 + """ def __init__(self, length=8) -> None: super().__init__() self._results = [] @@ -579,7 +647,7 @@ def set_limitation(self, **kwargs): if value is False: self.sub_scanners[attr].disable() - logger.info(f'Limitations set to {self.limitation}') + logger.info(f'筛选条件已设置为 {self.limitation}') class DockScanner(ShipScanner): @@ -743,7 +811,7 @@ def _scan(self, image) -> None: if not results: self.retry += 1 self.debug_info['reposition_retry'] += 1 - logger.info(f'No ship was detected, reset the position. Retry {self.retry} time(s)') + logger.info(f'[退役-扫描] 未检测到舰船,重置位置。重试第 {self.retry} 次') self.reset_position() self.reposition(image, bound) results = self.scanner.scan(image, cached=False, output=False) @@ -846,7 +914,7 @@ def multi_scan(self, main) -> None: for k,v in self.debug_info.items(): f.write(f'{k} = {v}\n') - logger.info(f'debug info has been saved in {self.debug_folder}') + logger.info(f'[退役-扫描] 调试信息已保存到 {self.debug_folder}') def scan(self, image, cached=False, output=True) -> Union[List, None]: """请使用 multi_scan() 代替。""" diff --git a/module/retire/setting.py b/module/retire/setting.py index 48eea60df..e12f0e4a6 100644 --- a/module/retire/setting.py +++ b/module/retire/setting.py @@ -1,3 +1,14 @@ +"""退役设置管理模块。 + +管理快速退役的设置界面操作,包括进入/退出设置页面、 +切换退役选项(如退役稀有度范围、保留规则等)。 + +QuickRetireSetting 扩展 Setting 类,适配退役设置的 UI 样式。 +QuickRetireSettingHandler 提供设置页面的导航操作。 + +继承自 UI,利用页面导航能力。 +""" + from module.base.decorator import cached_property from module.retire.assets import * from module.ui.setting import Setting @@ -5,11 +16,20 @@ class QuickRetireSetting(Setting): + """快速退役设置项。 + + 检测退役设置选项的激活状态。 + """ + def is_option_active(self, option: Button) -> bool: return self.main.image_color_count(option, color=(255, 255, 255), threshold=221, count=50) class QuickRetireSettingHandler(UI): + """退役设置页面导航处理器。 + + 提供退役设置页面的进入和退出操作。 + """ def _retire_setting_enter(self): """ Pages: diff --git a/module/reward/assets.py b/module/reward/assets.py index 0a75ce3ae..738e9528e 100644 --- a/module/reward/assets.py +++ b/module/reward/assets.py @@ -1,3 +1,5 @@ +"""奖励模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/reward/reward.py b/module/reward/reward.py index 09847da45..422e21661 100644 --- a/module/reward/reward.py +++ b/module/reward/reward.py @@ -1,3 +1,7 @@ +"""奖励收取处理器,统一管理资源奖励和任务奖励的收取。 +支持石油、金币、经验奖励领取和任务奖励收取。 +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.base.timer import Timer @@ -30,8 +34,8 @@ def reward_receive(self, oil, coin, exp): if not oil and not coin and not exp: return False - logger.hr('Reward receive') - logger.info(f'oil={oil}, coin={coin}, exp={exp}') + logger.hr('领取奖励') + logger.info(f'[奖励-领取] 石油={oil}, 金币={coin}, 经验={exp}') confirm_timer = Timer(1, count=3).start() # 设置点击间隔为 0.3 秒,因为游戏无法响应过快的点击。 click_timer = Timer(0.3) @@ -53,7 +57,7 @@ def reward_receive(self, oil, coin, exp): if confirm_timer.reached(): break - logger.info('Reward receive end') + logger.info('[奖励-领取] 奖励领取结束') return True def _reward_get_state(self): @@ -107,7 +111,7 @@ def _reward_mission_claim_receive(self): in: 未知弹窗 out: page_mission """ - logger.info('Mission claim receive') + logger.info('[奖励-任务] 领取任务奖励') timeout = Timer(2, count=6).start() for _ in self.loop(): if self.ui_page_appear(page_mission): @@ -115,7 +119,7 @@ def _reward_mission_claim_receive(self): if state: return state if timeout.reached(): - logger.warning('Wait mission receive timeout') + logger.warning('[奖励-任务] 等待任务领取超时') return 'timeout' else: timeout.reset() @@ -161,14 +165,14 @@ def _reward_mission_collect(self): """ state = self._reward_wait_mission_list() while 1: - logger.attr('MissionState', state) + logger.attr('任务状态', state) self.device.stuck_record_clear() self.device.click_record_clear() if state == 'timeout': - logger.warning('Reward wait mission list timeout') + logger.warning('[奖励-任务] 等待任务列表超时') return state if state in [MISSION_EMPTY, MISSION_UNFINISH]: - logger.info('Mission collect finished') + logger.info('[奖励-任务] 任务收集完成') break elif state in [MISSION_MULTI, MISSION_SINGLE]: # 清除以下资源的已有间隔计时器 @@ -177,7 +181,7 @@ def _reward_mission_collect(self): state = self._reward_mission_claim_receive() continue else: - logger.warning('Empty mission state, mission collect finished') + logger.warning('[奖励-任务] 空任务状态,任务收集完成') return state @@ -199,7 +203,7 @@ def _reward_mission_weekly(self): bool: 是否已处理。 """ if not self.image_color_count(MISSION_WEEKLY_RED_DOT, color=(206, 81, 66), threshold=221, count=20): - logger.info('No MISSION_WEEKLY_RED_DOT') + logger.info('[奖励-任务] 没有每周任务红点') return False self.reward_side_navbar_ensure(upper=5) @@ -216,10 +220,10 @@ def reward_mission_notice(self): in: page_main """ if self.appear(MISSION_NOTICE): - logger.info('Found mission notice MISSION_NOTICE') + logger.info('[奖励-任务] 发现任务提示 MISSION_NOTICE') return True if self.image_color_count(MISSION_NOTICE_WHITE, color=(214, 117, 99), threshold=221, count=20): - logger.info('Found mission notice MISSION_NOTICE_WHITE') + logger.info('[奖励-任务] 发现任务提示 MISSION_NOTICE_WHITE') return True return False @@ -241,7 +245,7 @@ def reward_mission(self, daily=True, weekly=True): """ if not daily and not weekly: return False - logger.hr('Mission reward') + logger.hr('任务奖励') if not self.reward_mission_notice(): return False diff --git a/module/server_checker.py b/module/server_checker.py index fcc25e94d..ed8b29858 100644 --- a/module/server_checker.py +++ b/module/server_checker.py @@ -1,3 +1,11 @@ +"""游戏服务器状态检查器。 + +通过外部 API 查询碧蓝航线各服务器(CN/EN/JP/TW)的在线状态。 +在任务调度前检查服务器是否可用,避免在维护期间执行无效操作。 + +状态检查结果通过队列缓存,支持定时刷新和即时查询。 +""" + from collections import deque from json import JSONDecodeError @@ -10,7 +18,20 @@ class ServerChecker: - # 震惊 ALAS 竟然公然收集用户IP地址 + """游戏服务器状态检查器。 + + 通过 HTTP API 查询指定服务器的在线状态,支持: + - 单服务器状态查询 + - 全服务器状态查询 + - 服务器列表获取 + + Attributes: + _server (str): 目标服务器标识,如 'cn'、'en'、'jp'、'tw',或 'disabled'。 + _state (deque): 状态历史队列(最大长度 2),用于状态变化检测。 + _recover (bool): 服务器是否从不可用状态恢复。 + _retry (bool): 是否需要重试。 + """ + def __init__(self, server: str) -> None: self._base: str = 'http://sc.shiratama.cn' self._api: dict = { @@ -59,10 +80,10 @@ def _load_server(self) -> None: j = resp.json() if j['state'] != 1: self._state.append(True) - logger.info(f'Server "{self._server}" is available.') + logger.info(f'[服务器检查] 服务器 "{self._server}" 可用。') else: self._state.append(False) - logger.info(f'Server "{self._server}" is under maintenance.') + logger.info(f'[服务器检查] 服务器 "{self._server}" 维护中。') # 检查 API 服务端是否已停止更新 if j['last_update'] > self._timestamp: @@ -71,13 +92,13 @@ def _load_server(self) -> None: else: self._expired += 1 if self._expired > 3: - logger.warning(f'Timestamp {self._timestamp} has not been updated for 3 times.') + logger.warning(f'[服务器检查] 时间戳 {self._timestamp} 已3次未更新。') elif resp.status_code == 404: # API 数据库可能未收录新增服务器(如"长弓计划"), # 检查本地服务器列表确认该服务器是否真实存在 if self._server_in_local_list(): self._state.append(True) - logger.info(f'Server "{self._server}" is available (local verified, API unknown).') + logger.info(f'[服务器检查] 服务器 "{self._server}" 可用(本地已验证,API未知)。') else: self._state.append(False) raise ScriptError(f'Server "{self._server}" does not exist!') @@ -85,7 +106,7 @@ def _load_server(self) -> None: raise ScriptError(f'Get status_code {resp.status_code}. Response is {resp.text}') except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as e: logger.error(e) - logger.error('Timeout while connecting to server checker API.') + logger.error('连接服务器检查API超时。') if self._retry: self._state.append(False) else: @@ -120,13 +141,12 @@ def check_now(self) -> None: else: if self._timer.limit < 600: self._timer.limit += 120 - logger.info(f'Server checker will retry after {self._timer.limit}s') + logger.info(f'服务器检查er will retry after {self._timer.limit}s') self._timer.reset() except ScriptError as e: logger.warning(str(e)) - logger.warning('There may be something wrong with server checker.') - logger.warning('Please contact the developer to fix it.') - logger.warning('Server checker will be temporarily forced off.') + logger.warning('服务器检查可能有问题。') + logger.warning('请联系开发者修复。') self.reset() self._server = 'disabled' self._recover = True @@ -204,21 +224,21 @@ def fast_retry(self) -> bool: logger.attr('network_available', network_available) if network_available: - logger.info('Trigger fast retry.') + logger.info('触发快速重试。') last = self._state.copy() for _ in range(3): - logger.info(f'Retry {_ + 1} times ...') + logger.info(f'重试 {_ + 1} times ...') self._load_server() if self._state[0]: self._retry = False self._state.extend(last) return True - logger.error('Cannot connect to API. Please check you network or disable server checker.') + logger.error('无法连接API. Please check you network or disable server checker.') self._retry = False self._state.extend(last) return False else: self._retry = False - logger.error('Network is unavailable. Please check your network status.') + logger.error('网络不可用. Please check your network status.') return False diff --git a/module/shipyard/assets.py b/module/shipyard/assets.py index 108b20043..f28e7f5cd 100644 --- a/module/shipyard/assets.py +++ b/module/shipyard/assets.py @@ -1,3 +1,5 @@ +"""船坞模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/shipyard/shipyard_reward.py b/module/shipyard/shipyard_reward.py index 788e8f5d6..21b1796b5 100644 --- a/module/shipyard/shipyard_reward.py +++ b/module/shipyard/shipyard_reward.py @@ -1,3 +1,28 @@ +""" +船坞蓝图购买模块。 + +自动化船坞(Shipyard)中的科研蓝图购买与使用流程。 +支持 PR(科研船)和 DR(决战方案)两种稀有度的蓝图管理。 + +主要功能: + - 从主页面读取当前金币余额,用于购买计算 + - 根据金币余额和蓝图价格阶梯计算可购买数量 + - 自动进入船坞界面,定位目标舰船并执行蓝图购买 + - 使用目标舰船的多余蓝图 + - 每日防重复执行(基于服务器更新时间 04:00) + +价格阶梯: + - PR 蓝图:第1-2张免费,之后阶梯递增至 1500 金币/张 + - DR 蓝图:第1-2张免费,之后阶梯递增至 6000 金币/张 + +依赖关系: + 继承自 ShipyardUI(船坞界面操作),调用船坞导航、蓝图购买确认等底层方法。 + +Pages: + 任务入口页面:任意页面 + 任务结束页面:page_shipyard +""" + from module.base.timer import Timer from module.config.time_source import now as current_time from module.config.utils import get_server_last_update @@ -6,6 +31,7 @@ from module.shipyard.ui import ShipyardUI from module.ui.page import page_main, page_shipyard +# PR 蓝图价格阶梯:键为已购蓝图序号范围,值为对应单价(金币) PRBP_BUY_PRIZE = { (1, 2): 0, (3, 4): 150, @@ -13,6 +39,7 @@ (8, 9, 10): 600, (11, 12, 13, 14, 15): 1050, } +# DR 蓝图价格阶梯:键为已购蓝图序号范围,值为对应单价(金币) DRBP_BUY_PRIZE = { (1, 2): 0, (3, 4, 5, 6): 600, @@ -22,6 +49,28 @@ class RewardShipyard(ShipyardUI): + """ + 船坞蓝图购买任务处理器。 + + 负责执行船坞中科研蓝图的购买和使用逻辑。支持 PR(普通科研船) + 和 DR(决战方案)两种稀有度类型,按价格阶梯计算最优购买方案。 + + 属性: + _shipyard_bp_rarity (str): 当前蓝图稀有度,'PR' 或 'DR' + _coin_count (int): 当前金币余额,从主页面 OCR 获取 + + 配置项: + Shipyard_ShipIndex: PR 舰船索引 + Shipyard_BuyAmount: PR 购买数量 + Shipyard_ResearchSeries: PR 科研系列 + ShipyardDr_ShipIndex: DR 舰船索引 + ShipyardDr_BuyAmount: DR 购买数量 + ShipyardDr_ResearchSeries: DR 科研系列 + + Pages: + 任务入口页面:任意页面 + 任务结束页面:page_shipyard + """ _shipyard_bp_rarity = 'PR' _coin_count = 0 @@ -88,15 +137,15 @@ def _shipyard_calculate(self, start, count, pay=False): if pay: self._coin_count -= total else: - logger.info(f'Can only buy up to {(i - start)} ' - f'of the {count} BPs') + logger.info(f'最多只能购买 {(i - start)} ' + f'/ {count} 张蓝图') return i, i - start total += cost if pay: self._coin_count -= total else: - logger.info(f'Can buy all {count} BPs') + logger.info(f'可以购买全部 {count} 张蓝图') return i + 1, count def _shipyard_buy_calc(self, start, count): @@ -117,7 +166,7 @@ def _shipyard_buy(self, count): Args: count (int): 待购买总数 """ - logger.hr('shipyard_buy') + logger.hr('船坞购买') prev = 1 start, count = self._shipyard_buy_calc(prev, count) while count > 0: @@ -152,7 +201,7 @@ def _shipyard_use(self, index): Args: index (int): 目标舰船索引 """ - logger.hr('shipyard_use') + logger.hr('船坞使用') count = self._shipyard_get_bp_count(index) while count > 0: if not self._shipyard_buy_enter() or \ @@ -181,10 +230,10 @@ def shipyard_run(self, series, index, count): bool: 是否执行了购买流程 """ if count <= 0: - logger.info('Shipyard buy amount is 0, skip') + logger.info('船坞购买数量为0,跳过') return False if index <= 0: - logger.info('Shipyard ship index is 0, skip') + logger.info('船坞舰船索引为0,跳过') return False # 船坞页面中金币 OCR 困难(文字和数字右对齐导致混淆) @@ -203,7 +252,7 @@ def shipyard_run(self, series, index, count): if self._coin_count > 0: break if timeout.reached(): - logger.warning('Assumes that OCR_COIN is in the right place') + logger.warning('假设OCR_COIN在正确位置') break self.ui_goto(page_shipyard) @@ -235,24 +284,24 @@ def run(self): self.config.Scheduler_Enable = False self.config.task_stop() - logger.hr('Shipyard DR', level=1) - logger.attr('ShipyardDr_LastRun', self.config.ShipyardDr_LastRun) + logger.hr('船坞DR', level=1) + logger.attr('船坞DR上次运行', self.config.ShipyardDr_LastRun) if not dr_enabled: - logger.info('Task Shipyard DR is not configured, skip') + logger.info('船坞DR任务未配置,跳过') elif self.config.ShipyardDr_LastRun > get_server_last_update('04:00'): - logger.warning('Task Shipyard DR has already been run today, skip') + logger.warning('船坞DR任务今天已运行,跳过') else: self._shipyard_bp_rarity = 'DR' self.shipyard_run(series=self.config.ShipyardDr_ResearchSeries, index=self.config.ShipyardDr_ShipIndex, count=self.config.ShipyardDr_BuyAmount) - logger.hr('Shipyard PR', level=1) - logger.attr('Shipyard_LastRun', self.config.Shipyard_LastRun) + logger.hr('船坞PR', level=1) + logger.attr('船坞PR上次运行', self.config.Shipyard_LastRun) if not pr_enabled: - logger.info('Task Shipyard PR is not configured, skip') + logger.info('船坞PR任务未配置,跳过') elif self.config.Shipyard_LastRun > get_server_last_update('04:00'): - logger.warning('Task Shipyard PR has already been run today, stop') + logger.warning('船坞PR任务今天已运行,停止') self.config.task_delay(server_update=True) self.config.task_stop() else: diff --git a/module/shipyard/ui.py b/module/shipyard/ui.py index 811acbdac..a7d599d67 100644 --- a/module/shipyard/ui.py +++ b/module/shipyard/ui.py @@ -1,3 +1,7 @@ +"""船坞系统 UI 操作模块,处理船坞页面的导航和交互。 +包括系列选择、蓝图计数读取、开发/研究等级 OCR、 +以及适配不同服务器的特殊 UI 元素识别。""" + from module.base.decorator import cached_property from module.base.timer import Timer from module.base.utils import area_pad @@ -45,8 +49,7 @@ def _shipyard_cannot_strengthen(self): or self.appear(SHIPYARD_PROGRESS_FATE, offset=(20, 20)) \ or self.appear(SHIPYARD_LEVEL_NOT_ENOUGH_FATE, offset=(20, 20)) \ or self.appear(SHIPYARD_LEVEL_NOT_ENOUGH_DEV, offset=(20, 20)): - logger.info('Ship at full strength for current level, ' - 'no more BPs can be consumed') + logger.info('当前等级舰船已达到最大强化,无法继续消耗蓝图') return True return False @@ -102,8 +105,7 @@ def _shipyard_ensure_index(self, count, skip_first_screenshot=True): int: 无法消耗的剩余蓝图数量,None 表示异常 """ if count < 0: - logger.warning('_shipyard_ensure_index --> Non-positive ' - '\'count\' cannot continue') + logger.warning('[船坞-UI] count 非正数,无法继续') return None current = diff = 0 @@ -115,7 +117,7 @@ def _shipyard_ensure_index(self, count, skip_first_screenshot=True): plus, minus, current = self._shipyard_get_total() if current == count: - logger.info(f'Capable of consuming all {count} BPs') + logger.info(f'能够消耗全部 {count} 张蓝图') return 0 diff = count - current @@ -123,8 +125,8 @@ def _shipyard_ensure_index(self, count, skip_first_screenshot=True): self.device.multi_click(button, n=diff, interval=(0.3, 0.5)) self.device.sleep((0.3, 0.5)) - logger.info(f'Current interface does not allow consumption of {count} BPs\n') - logger.info(f'Capable of consuming at most {current} of the {count} BPs') + logger.info(f'[船坞-UI] 当前界面无法消耗 {count} 张蓝图') + logger.info(f'最多能够消耗 {current} / {count} 张蓝图') return diff def _shipyard_get_bp_count(self, index=0): @@ -139,7 +141,7 @@ def _shipyard_get_bp_count(self, index=0): """ # index(config.SHIPYARD_INDEX) 从 1 开始 if index <= 0 or index > len(SHIPYARD_BP_COUNT_GRID.buttons): - logger.warning(f'Cannot parse for count from index {index}') + logger.warning(f'[船坞-UI] 无法从索引 {index} 解析数量') return -1 result = OCR_SHIPYARD_BP_COUNT_GRID.ocr(self.device.image) @@ -174,7 +176,7 @@ def _shipyard_set_series(self, series=1, skip_first_screenshot=True): bool: 是否设置成功 """ if series <= 0 or series > len(SHIPYARD_SERIES_GRID.buttons): - logger.warning(f'Research Series {series} is not selectable') + logger.warning(f'科研系列 {series} 不可选择') return False self.ui_click(SHIPYARD_SERIES_SELECT_ENTER, appear_button=self._shipyard_in_ui, @@ -217,7 +219,7 @@ def shipyard_bottom_navbar_ensure(self, left=None, right=None, skip_first_screen right = None if left is not None: if left <= 0 or left > len(SHIPYARD_FACE_GRID.buttons): - logger.warning(f'Index for bottom Navbar {left} is not selectable') + logger.warning(f'[船坞-UI] 导航栏索引 {left} 不可选择') return False ensured = False @@ -254,7 +256,7 @@ def shipyard_set_focus(self, series=1, index=1, skip_first_screenshot=True): bool: 是否设置成功 """ if series > 2 and index > 5: - logger.warning(f'Research Series {series} is limited to indexes 1-5, cannot set focus to index {index}') + logger.warning(f'[船坞-UI] 科研系列 {series} 仅限索引 1-5,无法设置焦点到索引 {index}') return False return self._shipyard_set_series(series, skip_first_screenshot) \ and self.shipyard_bottom_navbar_ensure(left=index, skip_first_screenshot=skip_first_screenshot) @@ -325,10 +327,10 @@ def _shipyard_buy_confirm(self, text, skip_first_screenshot=True): if ocr_timer.reached(): # 未能检测到正常退出,回退到 OCR 检查 - logger.warning('Failed to detect for normal exit routine, resort to OCR check') + logger.warning('[船坞-UI] 未能检测到正常退出流程,回退到OCR检查') _, _, current = self._shipyard_get_total() if not current: - logger.info('Confirm action has completed, setting flag for exit') + logger.info('确认操作已完成,设置退出标志') self.interval_reset(button) success = True ocr_timer.reset() @@ -384,8 +386,7 @@ def _shipyard_buy_enter(self): """ if self.appear(SHIPYARD_RESEARCH_INCOMPLETE, offset=(20, 20)) \ or self.appear(SHIPYARD_RESEARCH_IN_PROGRESS, offset=(20, 20)): - logger.warning('Cannot enter buy interface, focused ' - 'ship has not yet been fully researched') + logger.warning('[船坞-UI] 无法进入购买界面,当前舰船尚未完成研发') return False if self.appear(SHIPYARD_RESEARCH_COMPLETE, offset=(20, 20)): diff --git a/module/shipyard/ui_globals.py b/module/shipyard/ui_globals.py index ef619247a..099321e10 100644 --- a/module/shipyard/ui_globals.py +++ b/module/shipyard/ui_globals.py @@ -1,3 +1,7 @@ +"""船坞系统 UI 全局资源定义,声明船坞页面中使用的所有按钮网格和 OCR 实例。 +包括角色头像网格、蓝图计数网格、系列选择网格、 +开发等级和研究等级等 OCR 数字识别器。""" + from module.base.button import ButtonGrid from module.ocr.ocr import Digit from module.shipyard.assets import * diff --git a/module/shop/assets.py b/module/shop/assets.py index a5279db8b..bddfbbc15 100644 --- a/module/shop/assets.py +++ b/module/shop/assets.py @@ -1,3 +1,5 @@ +"""商店模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/shop/base.py b/module/shop/base.py index ba5b2d0d5..8d309d015 100644 --- a/module/shop/base.py +++ b/module/shop/base.py @@ -1,3 +1,15 @@ +""" +商店系统基类。 + +提供商店页面的通用框架,包括物品检测 (OCR + 模板匹配)、 +正则过滤器匹配与排序、购买决策、遮挡物处理等。 +子类 (ShopMedal、VoucherShop 等) 重写 shop_items()、 +shop_filter、shop_currency() 等方法以适配不同商店的布局和货币类型。 + +物品过滤器支持 group/sub_genre/tier 三级匹配, +涵盖装备箱、书籍、改造图纸、科研蓝图、限定舰船等商品类型。 +""" + import re import numpy as np @@ -46,9 +58,13 @@ class ShopItem_250814(Item): - """ - 未售出的 ship_T2 的计算结果为 0.36,因此取 0.3 作为阈值, - 已售出商品的结果 < 0.2。 + """2025-08-14 新版商店物品,增加售罄状态检测。 + + 通过像素亮度判断商品是否已售罄。阈值 0.3: + 未售出商品的亮度均值 > 0.36,已售出商品 < 0.2。 + + Attributes: + 无额外属性,继承自 Item。 """ def predict_valid(self): @@ -57,6 +73,13 @@ def predict_valid(self): class ShopItemGrid(ItemGrid): + """商店物品网格,在基类基础上扩展正则过滤属性。 + + 为每个 Item 追加 group、sub_genre、tier 三个属性, + 供 FILTER 进行匹配和排序。书籍类物品会通过模板匹配 + 修正颜色和等级的误识别。 + """ + def predict(self, image, name=True, amount=True, cost=False, price=False, tag=False): """ 预测商店物品并填充过滤所需的扩展属性。 @@ -110,6 +133,11 @@ def predict(self, image, name=True, amount=True, cost=False, price=False, tag=Fa class ShopItemGrid_250814(ShopItemGrid): + """2025-08-14 新版商店物品网格,使用 ShopItem_250814 作为物品类。 + + 增加售罄计数功能,通过像素亮度区分在售和已售罄商品。 + """ + item_class = ShopItem_250814 def get_soldout_count(self, image): @@ -131,7 +159,7 @@ def get_soldout_count(self, image): item = self.item_class(image, button) if not item.is_valid: count += 1 - logger.attr('Item soldout', count) + logger.attr('物品售罄数', count) return count @@ -233,15 +261,15 @@ def shop_detect_items(self, image=None): # 获取 ShopItemGrid shop_items = self.shop_items() if shop_items is None: - logger.warning('Expected type \'ShopItemGrid\' but was None') + logger.warning('期望 ShopItemGrid 类型但实际为 None') return [] if self.config.SHOP_EXTRACT_TEMPLATE: if self.shop_template_folder: - logger.info(f'Extract item templates to {self.shop_template_folder}') + logger.info(f'提取物品模板到 {self.shop_template_folder}') shop_items.extract_template(image, self.shop_template_folder) else: - logger.warning('SHOP_EXTRACT_TEMPLATE enabled but shop_template_folder is not set, skip extracting') + logger.warning('SHOP_EXTRACT_TEMPLATE 已启用但 shop_template_folder 未设置,跳过提取') shop_items.predict( image, @@ -258,12 +286,12 @@ def shop_detect_items(self, image=None): if len(items): min_row = grids[0, 0].area[1] row = [str(item) for item in items if item.button[1] == min_row] - logger.info(f'Shop row 1: {row}') + logger.info(f'[商店] 第1行: {row}') row = [str(item) for item in items if item.button[1] != min_row] - logger.info(f'Shop row 2: {row}') + logger.info(f'[商店] 第2行: {row}') return items else: - logger.info('No shop items found') + logger.info('未找到商店物品') return [] def shop_obstruct_handle(self): @@ -278,18 +306,18 @@ def shop_obstruct_handle(self): """ # 处理商店遮挡物 if self.appear(GET_SHIP, interval=1): - logger.info(f'Shop obstruct: {GET_SHIP} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_SHIP} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True # 锁定新获得的舰船 if self.handle_popup_confirm('SHOP_OBSTRUCT'): return True if self.appear(GET_ITEMS_1, interval=1): - logger.info(f'Shop obstruct: {GET_ITEMS_1} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_ITEMS_1} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True if self.appear(GET_ITEMS_3, interval=1): - logger.info(f'Shop obstruct: {GET_ITEMS_3} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_ITEMS_3} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True @@ -311,7 +339,7 @@ def shop_get_items(self, skip_first_screenshot=True): # 获取 ShopItemGrid shop_items = self.shop_items() if shop_items is None: - logger.warning('Expected type \'ShopItemGrid\' but was None') + logger.warning('期望 ShopItemGrid 类型但实际为 None') return [] # 循环预测以确保物品已加载且可被准确读取 @@ -329,10 +357,10 @@ def shop_get_items(self, skip_first_screenshot=True): if self.config.SHOP_EXTRACT_TEMPLATE: if self.shop_template_folder: - logger.info(f'Extract item templates to {self.shop_template_folder}') + logger.info(f'提取物品模板到 {self.shop_template_folder}') shop_items.extract_template(self.device.image, self.shop_template_folder) else: - logger.warning('SHOP_EXTRACT_TEMPLATE enabled but shop_template_folder is not set, skip extracting') + logger.warning('SHOP_EXTRACT_TEMPLATE 已启用但 shop_template_folder 未设置,跳过提取') shop_items.predict( self.device.image, @@ -344,13 +372,13 @@ def shop_get_items(self, skip_first_screenshot=True): ) if timeout.reached(): - logger.warning('Items loading timeout; continue and assumed has loaded') + logger.warning('物品加载超时,继续并假设已加载') break # 检查未加载的物品,因为游戏加载物品速度较慢 items = shop_items.items known = len([item for item in items if item.is_known_item]) - logger.attr('Item detected', known) + logger.attr('已检测物品数', known) if known == 0 or known != record: record = known continue @@ -367,12 +395,12 @@ def shop_get_items(self, skip_first_screenshot=True): if len(items): min_row = grids[0, 0].area[1] row = [str(item) for item in items if item.button[1] == min_row] - logger.info(f'Shop row 1: {row}') + logger.info(f'[商店] 第1行: {row}') row = [str(item) for item in items if item.button[1] != min_row] - logger.info(f'Shop row 2: {row}') + logger.info(f'[商店] 第2行: {row}') return items else: - logger.info('No shop items found') + logger.info('未找到商店物品') return [] def shop_check_item(self, item): @@ -431,6 +459,6 @@ def shop_get_item_to_buy(self, items): if not filtered: return None - logger.attr('Item_sort', ' > '.join([str(item) for item in filtered])) + logger.attr('物品排序', ' > '.join([str(item) for item in filtered])) return filtered[0] diff --git a/module/shop/clerk.py b/module/shop/clerk.py index 6e1aa4529..23155bb2b 100644 --- a/module/shop/clerk.py +++ b/module/shop/clerk.py @@ -1,3 +1,7 @@ +"""商店购买执行器,提供商品选择、库存检测和购买确认的核心逻辑。 +用于所有商店类型的商品购买流程,支持库存计数 OCR 和退役回收。 +""" + import re import cv2 @@ -45,12 +49,12 @@ def after_process(self, result): if re.match(r'^\d\d$', result): # 55 -> 5/5 new = f'{result[0]}/{result[1]}' - logger.info(f'StockCounter result {result} is revised to {new}') + logger.info(f'[商店-购买] 库存计数器结果 {result} 修正为 {new}') result = new if re.match(r'^\d{4,}$', result): # 1515 -> 15/15 new = f'{result[0:2]}/{result[2:4]}' - logger.info(f'StockCounter result {result} is revised to {new}') + logger.info(f'[商店-购买] 库存计数器结果 {result} 修正为 {new}') result = new return result @@ -99,8 +103,7 @@ def shop_get_choice(self, item): if postfix is not None: break - logger.warning('Failed to detect PR series, ' - 'app may be lagging or frozen') + logger.warning('未能检测到PR系列,应用可能卡顿或冻结') else: postfix = f'_{item.tier.upper()}' @@ -209,7 +212,7 @@ def shop_buy_select_ensure_index(image): current, remain, _ = OCR_SHOP_SELECT_STOCK.ocr(image) if not current: group_case = item.group.title() if len(item.group) > 2 else item.group.upper() - logger.info(f'{group_case}(s) out of stock; exit to prevent overbuying') + logger.info(f'{group_case} 已售罄;退出以防止超买') return limit return remain @@ -344,21 +347,21 @@ def shop_buy(self): bool: 是否成功(True 表示购买完成或余额不足,False 表示余额为 0) """ for _ in range(12): - logger.hr('Shop buy', level=2) + logger.hr('商店购买', level=2) # 先获取商品列表,利用固有延迟等待 OCR 货币识别更准确 items = self.shop_get_items() self.shop_currency() if self._currency <= 0: - logger.warning(f'Current funds: {self._currency}, stopped') + logger.warning(f'[商店-购买] 当前资金: {self._currency},停止') return False item = self.shop_get_item_to_buy(items) if item is None: - logger.info('Shop buy finished') + logger.info('[商店-购买] 购买完成') return True else: self.shop_buy_execute(item) continue - logger.warning('Too many items to buy, stopped') + logger.warning('购买物品过多,停止') return True diff --git a/module/shop/shop_core.py b/module/shop/shop_core.py index 4c17666ad..c163acf5b 100644 --- a/module/shop/shop_core.py +++ b/module/shop/shop_core.py @@ -1,3 +1,7 @@ +"""核心商店处理器,管理核心数据商品的过滤和购买。 +支持 2025-08-14 新 UI 布局,使用模板匹配识别商品。 +""" + from module.base.decorator import cached_property from module.logger import logger from module.shop.assets import * @@ -64,7 +68,7 @@ def shop_currency(self): int: 核心数据数量 """ self._currency = self.status_get_core() - logger.info(f'Core: {self._currency}') + logger.info(f'[商店-核心] 核心数据: {self._currency}') return self._currency def shop_interval_clear(self): @@ -103,7 +107,7 @@ def run(self): if not self.shop_filter: return - logger.hr('Core Shop', level=1) + logger.hr('[商店-核心] 核心商店', level=1) # 执行购买操作 self.shop_buy() diff --git a/module/shop/shop_general.py b/module/shop/shop_general.py index e59ddbd29..5582ba269 100644 --- a/module/shop/shop_general.py +++ b/module/shop/shop_general.py @@ -1,3 +1,7 @@ +"""通用商店处理器,支持金币和钻石两种货币购买商品。 +支持 2025-08-14 新 UI 布局,可配置是否允许使用钻石。 +""" + from module.base.decorator import cached_property from module.logger import logger from module.shop.base import ShopItemGrid, ShopItemGrid_250814 @@ -73,10 +77,10 @@ def shop_currency(self): while 1: self._currency = self.status_get_gold_coins() self.gems = self.status_get_gems() - logger.info(f'Gold coins: {self._currency}, Gems: {self.gems}') + logger.info(f'[商店-货币] 金币: {self._currency}, 钻石: {self.gems}') if self.currency_rechecked >= 3: - logger.warning('Failed to handle fix currency bug in general shop, skip') + logger.warning('[商店-货币] 无法修复通用商店货币bug,跳过') break break @@ -128,7 +132,7 @@ def shop_check_custom_item(self, item): if self.config.GeneralShop_BuySkinBox: if (not item.is_known_item()) and item.amount == 1 and item.cost == 'Coins' and item.price == 7000: # 装备外观箱无法通过模板匹配识别(颜色和外观持续变化) - logger.info(f'Item {item} is considered to be an equip skin box') + logger.info(f'[商店-商品] 物品 {item} 被认为是装备外观箱') if self._currency >= item.price: return True @@ -150,8 +154,8 @@ def _normalize_threshold(value, name): """ if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 600000: return value - logger.warning(f'{name}={value}, invalid value (expected int in 0-600000), ' - f'set to 0 to disable feature') + logger.warning(f'[商店-配置] {name}={value},无效值(期望0-600000的整数),' + f'设置为0以禁用功能') return 0 def _meowfficer_overflow_buy(self): @@ -173,11 +177,11 @@ def _meowfficer_overflow_buy(self): # 重新OCR识别金币(购买消耗物资后金币可能已变化) self.shop_currency() if self._currency <= overflow_coins: - logger.info(f'Gold coins {self._currency} <= OverflowCoins {overflow_coins}, skip meowfficer overflow buy') + logger.info(f'[商店-溢出] 金币 {self._currency} <= 溢出阈值 {overflow_coins},跳过指挥喵溢出购买') return - logger.hr('Meowfficer overflow buy', level=1) - logger.info(f'Gold coins {self._currency} > OverflowCoins {overflow_coins}, trigger meowfficer overflow buy') + logger.hr('指挥喵溢出购买', level=1) + logger.info(f'[商店-溢出] 金币 {self._currency} > 溢出阈值 {overflow_coins},触发指挥喵溢出购买') # 导航到指挥喵界面 self.ui_goto(page_meowfficer) @@ -222,7 +226,7 @@ def run(self): if not self.shop_filter: return - logger.hr('General Shop', level=1) + logger.hr('通用商店', level=1) # 执行购买操作,启用刷新时最多尝试 2 次 refresh = self.config.GeneralShop_Refresh diff --git a/module/shop/shop_guild.py b/module/shop/shop_guild.py index bdf3b007b..aff3effac 100644 --- a/module/shop/shop_guild.py +++ b/module/shop/shop_guild.py @@ -1,3 +1,7 @@ +"""舰队商店处理器,使用舰队币购买舰队商店专属商品。 +支持 2025-08-14 新 UI 布局,使用模板匹配识别商品。 +""" + from module.base.decorator import cached_property from module.logger import logger from module.shop.assets import * @@ -66,7 +70,7 @@ def shop_currency(self): int: 舰队币数量 """ self._currency = self.status_get_guild_coins() - logger.info(f'Guild coins: {self._currency}') + logger.info(f'[商店-舰队] 舰队币: {self._currency}') return self._currency def shop_interval_clear(self): @@ -106,7 +110,7 @@ def run(self): if not self.shop_filter: return - logger.hr('Guild Shop', level=1) + logger.hr('[商店-舰队] 舰队商店', level=1) # 执行购买操作,启用刷新时最多尝试 2 次 refresh = self.config.GuildShop_Refresh @@ -120,5 +124,5 @@ def run(self): if self.shop_refresh(): continue else: - logger.info('Guild coins < 110, skip refreshing') + logger.info('[商店-舰队] 舰队币 < 110,跳过刷新') break diff --git a/module/shop/shop_medal.py b/module/shop/shop_medal.py index 6c0bcd434..a1b25965a 100644 --- a/module/shop/shop_medal.py +++ b/module/shop/shop_medal.py @@ -1,3 +1,11 @@ +""" +勋章商店处理器。 + +通过模板匹配定位勋章图标,动态计算商品网格布局, +识别并过滤勋章商店中的商品,按配置购买优先级执行购买。 +使用自适应滚动条实现商品列表翻页。 +""" + import cv2 import numpy as np from scipy import signal @@ -18,7 +26,11 @@ class ShopAdaptiveScroll(AdaptiveScroll): - """商店自适应滚动条,通过颜色匹配检测滚动位置。""" + """商店自适应滚动条,通过颜色匹配检测滚动位置。 + + 使用 scipy 信号峰值检测在灰度反转图像上定位滚动条位置, + 生成布尔位置掩码数组用于判断滚动位置。 + """ def match_color(self, main): """通过峰值检测匹配滚动条颜色。 @@ -65,7 +77,10 @@ def match_color(self, main): class ShopPriceOcr(DigitYuv): - """商店价格 OCR,修正改造图纸的识别错误。""" + """商店价格 OCR 识别器,修正改造图纸的价格识别错误。 + + 在 YUV 色彩空间中识别商品价格,修正 '00' -> '100' 的常见误识别。 + """ def after_process(self, result): """OCR 后处理,修正 '00' 为 '100'(改造图纸场景)。""" @@ -116,7 +131,7 @@ def _get_medals(self): image = self.image_crop(area, copy=True) medals = TEMPLATE_MEDAL_ICON_3.match_multi(image, similarity=0.5, threshold=5) medals = Points([(0., m.area[1]) for m in medals]).group(threshold=5) - logger.attr('Medals_icon', len(medals)) + logger.attr('勋章图标数', len(medals)) return medals def wait_until_medal_appear(self, skip_first_screenshot=True): @@ -159,7 +174,7 @@ def shop_medal_grid(self): medals = self._get_medals() count = len(medals) if count == 0: - logger.warning('Unable to find medal icon, assume item list is at top') + logger.warning('未找到勋章图标,假设商品列表在顶部') origin_y = 228 delta_y = 223 row = 2 @@ -177,7 +192,7 @@ def shop_medal_grid(self): delta_y = abs(y1 - y2) row = 2 else: - logger.warning(f'Unexpected medal icon match result: {[m for m in medals]}') + logger.warning(f'意外的勋章图标匹配结果: {[m for m in medals]}') origin_y = 228 delta_y = 223 row = 2 @@ -232,7 +247,7 @@ def shop_currency(self): int: 勋章数量 """ self._currency = self.status_get_medal() - logger.info(f'Medal: {self._currency}') + logger.info(f'[商店-勋章] 勋章: {self._currency}') return self._currency def shop_has_loaded(self, items): @@ -295,20 +310,20 @@ def run(self): if not self.shop_filter: return - logger.hr('Medal Shop', level=1) + logger.hr('[商店-勋章] 勋章商店', level=1) # 执行购买操作 MEDAL_SHOP_SCROLL_250814.set_top(main=self) time.sleep(0.5) while 1: # 已售罄商品自动排序到后方,发现售罄则无需继续 if self.shop_items().get_soldout_count(self.device.image): - logger.info('Medal shop early stop') + logger.info('勋章商店提前停止') break self.shop_buy() if MEDAL_SHOP_SCROLL_250814.at_bottom(main=self): - logger.info('Medal shop reach bottom, stop') + logger.info('勋章商店到达底部,停止') break else: MEDAL_SHOP_SCROLL_250814.next_page(main=self, page=0.66) diff --git a/module/shop/shop_merit.py b/module/shop/shop_merit.py index d74e9af95..fb6d766b6 100644 --- a/module/shop/shop_merit.py +++ b/module/shop/shop_merit.py @@ -1,3 +1,7 @@ +"""功勋商店处理器,使用功勋点购买功勋商店专属商品。 +支持 2025-08-14 新 UI 布局,使用模板匹配识别商品。 +""" + from module.base.decorator import cached_property from module.logger import logger from module.shop.base import ShopItemGrid, ShopItemGrid_250814 @@ -64,7 +68,7 @@ def shop_currency(self): int: 功勋数量 """ self._currency = self.status_get_merit() - logger.info(f'Merit: {self._currency}') + logger.info(f'[商店-功勋] 功勋: {self._currency}') return self._currency def run(self): @@ -79,7 +83,7 @@ def run(self): return # 调用时应已在功勋商店界面 - logger.hr('Merit Shop', level=1) + logger.hr('[商店-功勋] 功勋商店', level=1) # 执行购买操作,启用刷新时最多尝试 2 次 refresh = self.config.MeritShop_Refresh diff --git a/module/shop/shop_reward.py b/module/shop/shop_reward.py index 82fc46f81..dbdbb3e98 100644 --- a/module/shop/shop_reward.py +++ b/module/shop/shop_reward.py @@ -1,3 +1,7 @@ +"""奖励商店调度器,统一调度各类型商店的购买任务。 +包含高频购买和完整购买两种运行模式。 +""" + from module.shop.assets import * from module.shop.shop_core import CoreShop_250814 from module.shop.shop_general import GeneralShop_250814 @@ -28,7 +32,7 @@ def run_frequent(self): if self.config.GeneralShop_Enable: GeneralShop_250814(self.config, self.device).run() else: - logger.info('General shop disabled, skip') + logger.info('[商店-通用] 通用商店已禁用,跳过') self.config.task_delay(server_update=True) @@ -47,7 +51,7 @@ def run_once(self): if self.config.MeritShop_Enable: MeritShop_250814(self.config, self.device).run() else: - logger.info('Merit shop disabled, skip') + logger.info('[商店-军需] 军需商店已禁用,跳过') self.device.click_record_clear() self.shop_nav_250814.set(NAV_GENERAL, main=self) @@ -55,7 +59,7 @@ def run_once(self): if self.config.GuildShop_Enable: GuildShop_250814(self.config, self.device).run() else: - logger.info('Guild shop disabled, skip') + logger.info('[商店-舰队] 舰队商店已禁用,跳过') # 核心限定、核心月度、勋章、原型 self.device.click_record_clear() @@ -64,7 +68,7 @@ def run_once(self): if self.config.CoreShop_Enable: CoreShop_250814(self.config, self.device).run() else: - logger.info('Core shop disabled, skip') + logger.info('[商店-核心] 核心商店已禁用,跳过') self.device.click_record_clear() self.shop_nav_250814.set(NAV_MONTHLY, main=self) @@ -72,7 +76,7 @@ def run_once(self): if self.config.MedalShop2_Enable: MedalShop2_250814(self.config, self.device).run() else: - logger.info('Medal shop disabled, skip') + logger.info('[商店-勋章] 勋章商店已禁用,跳过') self.config.task_delay(server_update=True) diff --git a/module/shop/shop_select_globals.py b/module/shop/shop_select_globals.py index 9ec1cdb48..8f62f3a05 100644 --- a/module/shop/shop_select_globals.py +++ b/module/shop/shop_select_globals.py @@ -1,3 +1,7 @@ +"""商店选择界面的全局常量定义,包含网格布局和商品选择映射。 +定义了舰队商店和勋章商店的商品类型、子选项和选择顺序。 +""" + from module.base.button import ButtonGrid # Known Secondary Grid Sizes diff --git a/module/shop/shop_status.py b/module/shop/shop_status.py index 788f1dace..2a6b348a1 100644 --- a/module/shop/shop_status.py +++ b/module/shop/shop_status.py @@ -1,3 +1,11 @@ +""" +商店资源余额监测模块。 + +通过 OCR 提取各类商店的货币余额(钻石、金币、勋章、功勋、 +大舰队币、核心数据、代币等),并将数据同步至 Dashboard。 +各商店子类通过继承 ShopStatus 获取当前货币数量。 +""" + # 此文件专门用于监测普通商店(包含勋章、功勋、核心商店等)中的资源余额状态。 # 通过 OCR 提取钻石、金币、各类奖章的数值,并利用 LogRes 类将数据同步至 Dashboard。 import module.config.server as server @@ -27,6 +35,15 @@ OCR_SHOP_VOUCHER = Digit(SHOP_VOUCHER, letter=(255, 255, 255), name='OCR_SHOP_VOUCHER') class ShopStatus(UI): + """商店货币余额读取器。 + + 提供各商店货币类型的 OCR 读取接口,同时将余额数据 + 同步至 LogRes Dashboard。子类商店通过继承此类获取 + shop_currency() 的默认实现。 + + Attributes: + _currency (int): 当前货币余额缓存。 + """ def status_get_gold_coins(self): """ Returns: diff --git a/module/shop/shop_voucher.py b/module/shop/shop_voucher.py index 069dc0354..d5d6a8a01 100644 --- a/module/shop/shop_voucher.py +++ b/module/shop/shop_voucher.py @@ -1,3 +1,11 @@ +""" +代币商店处理器(大世界商店)。 + +通过模板匹配定位代币图标,动态计算商品网格布局, +识别并过滤代币商店中的商品,按配置购买优先级执行购买。 +支持单次购买日志档案商品的 run_once() 方法。 +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property, del_cached_property from module.base.timer import Timer @@ -19,6 +27,14 @@ class VoucherShop(ShopClerk, ShopStatus): + """代币商店处理器(大世界商店)。 + + 通过模板匹配定位代币图标来动态计算商品网格, + 结合过滤器配置自动购买代币商店商品。 + 支持普通购买流程和单次购买日志档案两种模式。 + + Pages: in: page_shop (voucher shop tab) + """ @cached_property def shop_filter(self): """获取凭证商店过滤器。 @@ -40,7 +56,7 @@ def _get_vouchers(self): left_column = self.image_crop((305, 306, 1256, 646), copy=False) vouchers = TEMPLATE_VOUCHER_ICON.match_multi(left_column, similarity=0.75, threshold=5) vouchers = Points([(0., v.area[1]) for v in vouchers]).group(threshold=5) - logger.attr('Vouchers_icon', len(vouchers)) + logger.attr('代币图标数', len(vouchers)) return vouchers def wait_until_voucher_appear(self, skip_first_screenshot=True): @@ -79,7 +95,7 @@ def shop_grid(self): vouchers = self._get_vouchers() count = len(vouchers) if count == 0: - logger.warning('Unable to find voucher icon, assume item list is at top') + logger.warning('未找到代币图标,假设商品列表在顶部') origin_y = 200 delta_y = 191 row = 2 @@ -97,7 +113,7 @@ def shop_grid(self): delta_y = abs(y1 - y2) row = 2 else: - logger.warning(f'Unexpected voucher icon match result: {[v.area for v in vouchers]}') + logger.warning(f'意外的代币图标匹配结果: {[v.area for v in vouchers]}') origin_y = 200 delta_y = 191 row = 2 @@ -157,7 +173,7 @@ def shop_currency(self): int: 凭证数量 """ self._currency = self.status_get_voucher() - logger.info(f'Voucher: {self._currency}') + logger.info(f'凭证: {self._currency}') return self._currency def shop_interval_clear(self): @@ -259,7 +275,7 @@ def run(self): return # 调用时应已在凭证商店界面 - logger.hr('Voucher Shop', level=1) + logger.hr('[商店-代币] 代币商店', level=1) self.wait_until_voucher_appear() # 执行购买操作 @@ -267,7 +283,7 @@ def run(self): while 1: self.shop_buy() if VOUCHER_SHOP_SCROLL.at_bottom(main=self): - logger.info('Voucher Shop reach bottom, stop') + logger.info('[商店-代币] 代币商店到达底部,停止') break else: VOUCHER_SHOP_SCROLL.next_page(main=self) @@ -287,21 +303,21 @@ def run_once(self): self.shop_filter = 'LoggerArchive' # 调用时应已在凭证商店界面 - logger.hr('Voucher Shop Once', level=1) + logger.hr('[商店-代币] 代币商店单次购买', level=1) self.wait_until_voucher_appear() # 执行购买操作 items = self.shop_get_items() self.shop_currency() if self._currency <= 0: - logger.warning(f'Current funds: {self._currency}, stopped') + logger.warning(f'[商店-代币] 当前资金: {self._currency},停止') return False item = self.shop_get_item_to_buy(items) if item is None: - logger.info('No logger archives available for purchase') + logger.info('[商店-代币] 无记录仪档案可购买') return False self.shop_buy_execute(item) - logger.info('Purchased single logger archive') + logger.info('已购买单个记录仪档案') return True diff --git a/module/shop/ui.py b/module/shop/ui.py index 8f0891fb0..537f3e59a 100644 --- a/module/shop/ui.py +++ b/module/shop/ui.py @@ -1,3 +1,7 @@ +"""商店 UI 导航处理,管理商店页面的标签切换和导航栏。 +支持 2025-08-14 新 UI 的底部导航栏和标签页切换。 +""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.handler.assets import POPUP_CONFIRM @@ -99,7 +103,7 @@ def shop_refresh(self): Returns: bool: 是否刷新成功 """ - logger.info('Shop refresh') + logger.info('[商店-UI] 刷新商店') refreshed = False # 点击刷新按钮,等待确认弹窗出现 @@ -117,7 +121,7 @@ def shop_refresh(self): self.device.click(SHOP_REFRESH) continue if self.image_color_count(SHOP_REFRESH.button, color=(52, 74, 94), threshold=221, count=50): - logger.info('Refresh not available') + logger.info('[商店-UI] 刷新不可用') break # 不使用 continue,当作 SHOP_REFRESH 未匹配处理 self.interval_clear(SHOP_REFRESH) @@ -127,7 +131,7 @@ def shop_refresh(self): if self.appear(SHOP_BACK_ARROW, offset=(30, 30)): break if self.appear(SHOP_BUY_CONFIRM_MISTAKE, interval=3, offset=(200, 200)): - logger.warning('SHOP_BUY_CONFIRM_MISTAKE') + logger.warning('[商店-UI] 刷新确认错误') self.ui_click(SHOP_CLICK_SAFE_AREA, appear_button=POPUP_CONFIRM, check_button=SHOP_BACK_ARROW, offset=(20, 30), skip_first_screenshot=True) refreshed = False @@ -149,7 +153,7 @@ def ui_goto_shop(self): out: page_munitions """ if self.ui_get_current_page() == page_munitions: - logger.info(f'Already at {page_munitions}') + logger.info(f'[商店-UI] 已在 {page_munitions}') return self.ui_ensure(page_academy) diff --git a/module/shop_event/assets.py b/module/shop_event/assets.py index 413d2f786..30d2c81f9 100644 --- a/module/shop_event/assets.py +++ b/module/shop_event/assets.py @@ -1,3 +1,5 @@ +"""活动商店模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/shop_event/clerk.py b/module/shop_event/clerk.py index 7b25e9aa0..aeed163d8 100644 --- a/module/shop_event/clerk.py +++ b/module/shop_event/clerk.py @@ -1,3 +1,12 @@ +""" +活动商店店员逻辑。 + +负责活动商店的购买流程,包括商品网格检测、商品定位与筛选、 +数量选择和确认购买。支持 PT/UR 点数货币类型。 +继承 EventShopUI 以复用商店界面导航能力。 + +Pages: in: EVENT_SHOP +""" import cv2 from module.base.button import ButtonGrid @@ -46,7 +55,7 @@ def _get_event_shop_grid(self): y = points[0][1] + DETECT_AREA[1] delta_y = 215 else: - logger.warning(f"Unexpected number of rows: {row}, assuming scroll at top.") + logger.warning(f"[活动商店-购买] 行数异常: {row},假设滚动条在顶部") y = 1 + DETECT_AREA[1] # Start position is 1 pixel lower than detect area delta_y = 215 @@ -75,19 +84,19 @@ def event_shop_get_items(self, scroll_pos=None): if len(shop_items): min_row = self.event_shop_items.grids[0, 0].area[1] row = [str(item) for item in shop_items if item.button[1] == min_row] - logger.info(f'Shop row 1: {row}') + logger.info(f'[活动商店-购买] 第1行: {row}') row = [str(item) for item in shop_items if item.button[1] != min_row] - logger.info(f'Shop row 2: {row}') + logger.info(f'[活动商店-购买] 第2行: {row}') return shop_items else: - logger.info('No shop items found') + logger.info('未找到商店物品') return [] def scan_all(self): items = [] self.device.click_record_clear() - logger.hr('Event Shop Scan', level=2) + logger.hr('活动商店扫描', level=2) EVENT_SHOP_SCROLL.set_top(main=self) while 1: new_items = self.event_shop_get_items(scroll_pos=EVENT_SHOP_SCROLL.cal_position(main=self)) @@ -97,14 +106,14 @@ def scan_all(self): new_second_row = [item for item in new_items if item.button[1] != new_items[0].button[1]] if len(old_last_row) == len(new_first_row) and all( old.name == new.name for old, new in zip(old_last_row, new_first_row)): - logger.info('Ignore duplicated items') + logger.info('[活动商店-购买] 忽略重复物品') items += new_second_row else: items += new_items else: items += new_items if EVENT_SHOP_SCROLL.at_bottom(main=self): - logger.info('Event shop reach bottom') + logger.info('活动商店到达底部') break else: EVENT_SHOP_SCROLL.next_page(main=self, page=0.66) @@ -118,12 +127,11 @@ def event_shop_buy_item(self, item_to_buy, amount=None): items = [item for item in items if item.name == item_to_buy.name and item.count == item_to_buy.count and item.price == item_to_buy.price] if len(items) == 0: - logger.error(f'Item {item_to_buy} not found at scroll position {scroll_pos}') - logger.warning(f'Will try to rerun the task.') + logger.error(f'[活动商店-购买] 物品 {item_to_buy} 在滚动位置 {scroll_pos} 未找到') + logger.warning(f'[活动商店-购买] 将尝试重新运行任务') raise ItemNotFoundError(f'Item {item_to_buy} not found at scroll position {scroll_pos}') elif len(items) > 1: - logger.warning(f'Multiple items found for {item_to_buy} at scroll position {scroll_pos}, ' - f'buying the first one') + logger.warning(f'[活动商店-购买] 在滚动位置 {scroll_pos} 找到多个物品 {item_to_buy},购买第一个') item = items[0] # For ship items, while it may have multiple stock, can only buy one at a time. if getattr(item, 'is_ship', False): @@ -180,15 +188,15 @@ def event_shop_handle_obstruct(self): if self.handle_get_meowfficer(): return True if self.appear(GET_SHIP, offset=(20, 20), interval=2): - logger.info(f'Shop obstruct: {GET_SHIP} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_SHIP} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True if self.appear(GET_ITEMS_1, offset=(20, 20), interval=2): - logger.info(f'Shop obstruct: {GET_ITEMS_1} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_ITEMS_1} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True if self.appear(GET_ITEMS_3, offset=(20, 20), interval=2): - logger.info(f'Shop obstruct: {GET_ITEMS_3} -> {SHOP_CLICK_SAFE_AREA}') + logger.info(f'商店遮挡: {GET_ITEMS_3} -> {SHOP_CLICK_SAFE_AREA}') self.device.click(SHOP_CLICK_SAFE_AREA) return True return False diff --git a/module/shop_event/item.py b/module/shop_event/item.py index e54455b4f..55fc4c3d5 100644 --- a/module/shop_event/item.py +++ b/module/shop_event/item.py @@ -69,13 +69,13 @@ def ocr(self, image, direct_ocr=False): parsed = [] for i in result_list: if not i or '/' not in i: - logger.warning(f'Invalid OCR result format: {i}') + logger.warning(f'[活动商店-物品] 无效的OCR结果格式: {i}') parsed.append([0, 0]) continue parts = i.split('/') if len(parts) != 2: - logger.warning(f'Invalid counter format: {i}') + logger.warning(f'[活动商店-物品] 无效的计数器格式: {i}') parsed.append([0, 0]) continue parsed.append([int(j) for j in parts]) @@ -83,12 +83,12 @@ def ocr(self, image, direct_ocr=False): return parsed else: if not result_list or '/' not in result_list: - logger.warning(f'Invalid OCR result: {result_list}') + logger.warning(f'[活动商店-物品] 无效的OCR结果: {result_list}') return [0, 0] parts = result_list.split('/') if len(parts) != 2: - logger.warning(f'Invalid counter format: {result_list}') + logger.warning(f'[活动商店-物品] 无效的计数器格式: {result_list}') return [0, 0] return [int(i) for i in parts] @@ -174,7 +174,7 @@ def correct_name_and_cost(self): elif self.price == URPT_PRICE_IN_PT and self.total_count == 500: self.name = 'URpt' elif self.name.isdigit(): - logger.warning(f'Unrecognized item with price {self.price} and total count {self.total_count}, ' + logger.warning(f'[活动商店-物品] 未识别的物品,价格 {self.price},总数 {self.total_count},' # f'defaulting to EquipSSR') f'saving image for analysis.') import os @@ -241,4 +241,4 @@ def predict(self, image, name=True, amount=True, cost=False, price=True, tag=Tru i.correct_name_and_cost() i.predict_genre() - return self.items \ No newline at end of file + return self.items diff --git a/module/shop_event/selector.py b/module/shop_event/selector.py index f9758a232..544541904 100644 --- a/module/shop_event/selector.py +++ b/module/shop_event/selector.py @@ -1,3 +1,13 @@ +""" +活动商店商品过滤选择器。 + +通过正则表达式定义商品分类过滤规则,支持按大类(装备/舰船/PT 等)、 +子类(SSR/SR 等)和层级(S1-S8/T1-T6)三级筛选。 +提供 Filter 实例用于匹配商品名称,并支持可选的数量后缀解析 +(如 "Cube:5" 表示购买 5 个心智魔方)。 + +Pages: in: EVENT_SHOP +""" import re from module.base.filter import Filter diff --git a/module/shop_event/shop_event.py b/module/shop_event/shop_event.py index 88b571aa0..d7cfe1f74 100644 --- a/module/shop_event/shop_event.py +++ b/module/shop_event/shop_event.py @@ -1,3 +1,14 @@ +"""活动商店模块。 + +提供碧蓝航线活动商店的自动化购买功能,包括: +- 活动点数(pt/URpt)的获取与余额管理 +- UR 舰船购买逻辑(含 URpt 不足时的自动兑换) +- 未获取物品(unobtained)的优先购买 +- 基于预设或自定义过滤器的批量购买策略 +- 自定义过滤器的消耗量追踪与自动禁用 + +支持多个活动商店标签页的自动切换与遍历。 +""" from typing import List, Tuple from module.base.decorator import del_cached_property @@ -20,8 +31,21 @@ class EventShop(EventShopClerk): - """ - Class for Event Shop operations with backend operations. + """活动商店自动化购买控制器。 + + 继承 EventShopClerk,实现完整的活动商店购买流程: + 1. 获取当前活动点数(pt / URpt) + 2. 优先处理 URpt 相关物品(UR 舰船、URpt 兑换、金币) + 3. 处理未获取物品(unobtained tag)的购买 + 4. 根据过滤器配置批量购买剩余物品 + 5. 活动结束后自动消耗自定义过滤器中的购买数量 + + 支持多个活动商店标签页的自动遍历,每个标签页独立执行购买逻辑。 + + Attributes: + pt (int): 当前活动点数余额。 + urpt (int): 当前 UR 活动点数余额。 + pt_preserved (int): 为后续购买预留的活动点数。 """ pt = 0 urpt = 0 @@ -37,7 +61,7 @@ def preserve_pt(self, amount: int): Preserve pt for future use. """ self.pt_preserved += amount - logger.info(f"Preserved {amount} pt for future use. Total preserved pt: {self.pt_preserved}") + logger.info(f"[活动商店] 保留 {amount} PT点数供后续使用。总保留PT: {self.pt_preserved}") def handle_items_related_with_urpt(self, items: List[EventShopItem], num_of_ships_to_buy: int = 2) \ -> Tuple[List[EventShopItem], List[EventShopItem]]: @@ -54,7 +78,7 @@ def handle_items_related_with_urpt(self, items: List[EventShopItem], num_of_ship that should be dealt with at last. """ if not self.event_shop_has_urpt: - logger.info("Event shop does not have URpt, skip handling URpt-related items.") + logger.info("[活动商店] 活动商店没有UR点数,跳过UR点数相关物品处理") return items, [] ship_items = [] @@ -77,17 +101,17 @@ def handle_items_related_with_urpt(self, items: List[EventShopItem], num_of_ship ship_items.sort(key=lambda item: item.price) if ship_items and num_of_ships_to_buy > 0: if len(ship_items) == 1 and num_of_ships_to_buy == 1: - logger.info("Only one ship item available and num_of_ships_to_buy is 1, skipping buying ships.") + logger.info("[活动商店] 只有一个舰船物品且购买数量为1,跳过购买舰船") else: ships_to_buy = ship_items[:num_of_ships_to_buy] - logger.info(f"Attempting to buy ship items: {[str(item) for item in ships_to_buy]}") + logger.info(f"[活动商店] 尝试购买舰船物品: {[str(item) for item in ships_to_buy]}") current_urpt = self.event_shop_get_urpt() while ships_to_buy: urpt_needed = sum([item.price for item in ships_to_buy]) if current_urpt >= urpt_needed: for item in ships_to_buy: self.event_shop_buy_item(item) - logger.info(f"Successfully bought ship items: {[str(item) for item in ships_to_buy]}") + logger.info(f"[活动商店] 成功购买舰船物品: {[str(item) for item in ships_to_buy]}") break else: if self.is_event_ended: @@ -98,19 +122,19 @@ def handle_items_related_with_urpt(self, items: List[EventShopItem], num_of_ship urpt_items[0].count -= (urpt_needed - current_urpt) for item in ships_to_buy: self.event_shop_buy_item(item) - logger.info(f"Successfully bought ship items: {[str(item) for item in ships_to_buy]}") + logger.info(f"[活动商店] 成功购买舰船物品: {[str(item) for item in ships_to_buy]}") break else: logger.warning( - f"Insufficient URpt to buy ships: {[str(item) for item in ships_to_buy]}. " - f"Skipping the most expensive one and retrying.") + f"[活动商店] UR点数不足以购买舰船: {[str(item) for item in ships_to_buy]}," + f"跳过最贵的并重试") ships_to_buy.pop() else: urpt_in_stock = urpt_items[0].count if urpt_items else 0 if current_urpt + urpt_in_stock >= urpt_needed: pt_needed = (urpt_needed - current_urpt) * URPT_PRICE_IN_PT self.preserve_pt(pt_needed) - logger.info(f"Preserved {pt_needed} pt to buy URpt for ships.") + logger.info(f"[活动商店] 保留 {pt_needed} PT点数用于购买舰船的UR点数") urpt_preserve = True while ships_to_buy and sum([item.price for item in ships_to_buy]) > current_urpt: ships_to_buy.pop() @@ -118,21 +142,20 @@ def handle_items_related_with_urpt(self, items: List[EventShopItem], num_of_ship for item in ships_to_buy: self.event_shop_buy_item(item) logger.info( - f"Successfully bought ship items: {[str(item) for item in ships_to_buy]}") + f"[活动商店] 成功购买舰船物品: {[str(item) for item in ships_to_buy]}") break else: - logger.warning("No ships can be bought with current URpt. Skipping buying ships.") + logger.warning("[活动商店] 当前UR点数无法购买舰船,跳过购买舰船") break else: - logger.warning("Insufficient URpt to buy ships even after buying all URpt in stock. " - "Skipping buying the most expensive ship.") + logger.warning("[活动商店] 购买所有UR点数后仍不足,跳过购买最贵的舰船") ships_to_buy.pop() if urpt_preserve: - logger.info("Skipping buying URpt and coins due to URpt preservation for ships.") + logger.info("[活动商店] 因保留UR点数购买舰船,跳过购买UR点数和物资") return other_items, [] else: - logger.info("Buy URpt and URpt-priced coins last.") + logger.info("[活动商店] 最后购买UR点数和UR点数定价的物资") return other_items, urpt_items + coin_items def handle_unobtained_items(self, items: List[EventShopItem], buy_unobtained_items=False) \ @@ -161,15 +184,15 @@ def handle_unobtained_items(self, items: List[EventShopItem], buy_unobtained_ite if not unobtained_items: return other_items, [] if not self.is_event_ended: - logger.info("Event is not ended, preserve pt for unobtained items. May also wait for event map drop.") + logger.info("[活动商店] 活动未结束,为未获取物品保留PT点数。也可等待活动地图掉落") self.preserve_pt(sum(item.price for item in unobtained_items)) return other_items, [] multiple_items = [] - logger.info(f"Attempting to buy unobtained items: {[str(item) for item in unobtained_items]}") + logger.info(f"[活动商店] 尝试购买未获取物品: {[str(item) for item in unobtained_items]}") for item in unobtained_items: self.event_shop_buy_item(item) - logger.info(f"Successfully bought unobtained item: {str(item)}") + logger.info(f"[活动商店] 成功购买未获取物品: {str(item)}") if item.count > 1: item.count -= 1 multiple_items.append(item) @@ -188,7 +211,7 @@ def calculate_affordable_amount(self, item: EventShopItem) -> int: elif item.cost == 'pt': return min(item.count, (self.pt - self.pt_preserved) // item.price) else: - logger.error(f"Unknown cost type: {item.cost} for item: {str(item)}") + logger.error(f"[活动商店] 未知的消耗类型: {item.cost},物品: {str(item)}") return 0 @staticmethod @@ -215,9 +238,9 @@ def _run(self): self.event_shop_load_ensure() items = self.scan_all() if not len(items): - logger.warning("No items found in event shop.") + logger.warning("[活动商店] 活动商店中未找到物品") return True - logger.hr("Event Shop buy", level=2) + logger.hr("活动商店购买", level=2) self.get_current_pts() items, urpt_related_items = self.handle_items_related_with_urpt(items, self.config.EventShop_BuyURShip) self.get_current_pts() @@ -234,14 +257,14 @@ def _run(self): items = FILTER.apply(items) items += urpt_related_items if not len(items): - logger.info("No items to buy after filtering.") + logger.info("[活动商店] 筛选后无可购买物品") return True - logger.attr('Item_sort', ' > '.join([str(item) for item in items])) + logger.attr('物品排序', ' > '.join([str(item) for item in items])) self.get_current_pts() - logger.attr("Pt_preserved", self.pt_preserved) + logger.attr("保留PT点数", self.pt_preserved) bought_amount = {} for item in items: - logger.hr(f"Attempting to buy item: {str(item)}", level=3) + logger.hr(f"尝试购买物品: {str(item)}", level=3) filter_amount_key = self.item_filter_amount_key(item, filter_amount) amount_limit = filter_amount.get(filter_amount_key) already_bought = bought_amount.get(filter_amount_key, 0) if filter_amount_key else 0 @@ -251,31 +274,31 @@ def _run(self): else max(amount_limit - already_bought, 0) ) if remaining_limit is not None and remaining_limit <= 0: - logger.info(f"Reach filter amount limit for item: {str(item)}") + logger.info(f"[活动商店] 达到物品筛选数量上限: {str(item)}") continue affordable_amount = self.calculate_affordable_amount(item) target_amount = item.count if remaining_limit is None else min(item.count, remaining_limit) buy_amount = min(affordable_amount, target_amount) if buy_amount <= 0: - logger.warning(f"Cannot afford to buy any of item: {str(item)}.") + logger.warning(f"[活动商店] 无法购买物品: {str(item)}") if self.is_event_ended: - logger.info("Event is ended, skip this item and continue to try buying other items.") + logger.info("[活动商店] 活动已结束,跳过此物品继续尝试购买其他物品") continue else: - logger.info("Event is not ended, stopping further purchases to avoid overspending.") + logger.info("[活动商店] 活动未结束,停止进一步购买以避免超支") break elif buy_amount < target_amount: - logger.warning(f"Can only afford to buy {buy_amount} of item: {str(item)}.") + logger.warning(f"[活动商店] 只能购买 {buy_amount} 个物品: {str(item)}") self.event_shop_buy_item(item, amount=buy_amount) if filter_amount_key: bought_amount[filter_amount_key] = already_bought + buy_amount if self.is_event_ended: - logger.info("Event is ended, continue to try buying other items.") + logger.info("[活动商店] 活动已结束,继续尝试购买其他物品") self.get_current_pts() continue else: - logger.info("Event is not ended, stopping further purchases to avoid overspending.") + logger.info("[活动商店] 活动未结束,停止进一步购买以避免超支") break else: if buy_amount < item.count: @@ -284,7 +307,7 @@ def _run(self): self.event_shop_buy_item(item) if filter_amount_key: bought_amount[filter_amount_key] = already_bought + buy_amount - logger.info(f"Successfully bought item: {str(item)}") + logger.info(f"[活动商店] 成功购买物品: {str(item)}") self.get_current_pts() # Consume custom filter amounts based on actual purchased quantities. @@ -302,10 +325,10 @@ def _run(self): changed = True if changed: new_filter = rebuild_filter_tokens(filter_tokens) - logger.attr('EventShop_Filter_Consumed', new_filter if new_filter else '(empty)') + logger.attr('活动商店过滤器已消耗', new_filter if new_filter else '(空)') self.config.EventShop_CustomFilter = new_filter if not new_filter.strip(): - logger.info('Event shop custom filter fully consumed, disabling EventShop task.') + logger.info('[活动商店] 自定义过滤器已完全消耗,禁用活动商店任务') self.config.Scheduler_Enable = False self.config.task_stop() return True @@ -327,14 +350,14 @@ def run(self): if self.appear(NAV_GENERAL, offset=(5, 5)): if self.appear(NO_NAV_EVENT_CHECK, offset=(5, 5)): - logger.info("There is no event shop currently. End the task.") + logger.info("[活动商店] 当前没有活动商店,结束任务") self.config.task_delay(server_update=True) return False else: self.ui_click(NAV_EVENT, check_button=NAV_EVENT, appear_button=NAV_GENERAL) count, navbar = self.event_shop_tab_count_and_navbar - logger.info(f"Detected {count} event shop(s). Start processing.") + logger.info(f"[活动商店] 检测到 {count} 个活动商店,开始处理") for i in range(count): navbar.set(main=self, left=i + 1) for _ in range(7): # Refresh up to 7 times to deal with buying failures diff --git a/module/shop_event/ui.py b/module/shop_event/ui.py index bd04958dd..e561cf895 100644 --- a/module/shop_event/ui.py +++ b/module/shop_event/ui.py @@ -1,3 +1,12 @@ +""" +活动商店界面导航与状态检测。 + +提供活动商店的页面检测、余额 OCR、滚动条控制和标签栏导航。 +包含自定义滚动条 EventShopScroll 以适配活动商店特有样式, +支持商店页面可用性检测(时间窗口校验)和货币余额读取。 + +Pages: in: EVENT_SHOP +""" import numpy as np import re from datetime import datetime, timedelta @@ -76,7 +85,7 @@ def event_shop_tab_count_and_navbar(self): tab = color_similarity_2d(image, color=(232, 238, 240)) index = np.where(np.average(tab > 221, axis=0) > 0.5)[0] count = (area[2] - area[0] + gap_x) // (len(index) + gap_x) - logger.info(f"Event shop tab count: {count}") + logger.info(f"活动商店标签数: {count}") delta_x = (area[2] - area[0] + gap_x) // count - gap_x grid = ButtonGrid((206, 92), (delta_x + gap_x, 44), (delta_x, 44), (count, 1), @@ -90,10 +99,10 @@ def event_shop_tab_count_and_navbar(self): @cached_property def event_shop_has_urpt(self): if self.image_color_count(SHOP_OCR_BALANCE_SECOND, OCR_EVENT_SHOP_URPT.letter, count=15): - logger.info("Event shop has urpt.") + logger.info("[活动商店-UI] 活动商店包含UR点数") return True else: - logger.info("Event shop has no urpt.") + logger.info("[活动商店-UI] 活动商店无UR点数") return False @cached_property @@ -104,7 +113,7 @@ def is_event_ended(self): pattern = r'(\d{4})\.(\d{1,2})\.(\d{1,2})' matches = re.findall(pattern, period) if not matches or len(matches) < 2: - logger.warning(f"Failed to read event deadline: {period}") + logger.warning(f"[活动商店-UI] 活动截止日期读取失败: {period}") return False y, m, d = matches[-1] deadline = datetime(int(y), int(m), int(d)) + timedelta(days=1) # server deadline @@ -115,7 +124,7 @@ def event_shop_load_ensure(self): ensure_timeout = Timer(3, count=6).start() for _ in self.loop(): if self.image_color_count(SHOP_OCR_BALANCE, OCR_EVENT_SHOP_PT.letter, count=15): - logger.info("Event shop loaded.") + logger.info("活动商店已加载。") break if ensure_timeout.reached(): raise GameStuckError('Waiting too long for EventShop to appear.') @@ -143,11 +152,11 @@ def get_oil(self, skip_first_screenshot=True): self.device.screenshot() if timeout.reached(): - logger.warning('Get oil timeout') + logger.warning('获取石油超时') break if not self.appear(SHOP_OCR_OIL_CHECK, offset=(10, 2)): - logger.info('No oil icon') + logger.info('无石油图标') continue ocr = Digit(SHOP_OCR_OIL, name='OCR_OIL', letter=(247, 247, 247), threshold=128) amount = ocr.ocr(self.device.image) @@ -158,7 +167,7 @@ def get_oil(self, skip_first_screenshot=True): def handle_get_meowfficer(self): if self.appear(MEOWFFICER_GET_CHECK, offset=(40, 40), interval=3): - logger.info(f'Getting Meowfficer rewards.') + logger.info(f'获取指挥喵奖励。') SWITCH_LOCK.set('lock', main=self) # Wait until info bar disappears self.ensure_no_info_bar(timeout=1) diff --git a/module/sos/assets.py b/module/sos/assets.py index 18fcd5b3f..3815fd6ae 100644 --- a/module/sos/assets.py +++ b/module/sos/assets.py @@ -1,3 +1,5 @@ +"""SOS模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/sos/sos.py b/module/sos/sos.py index 66f2863bd..832f0257b 100644 --- a/module/sos/sos.py +++ b/module/sos/sos.py @@ -1,3 +1,18 @@ +"""SOS 深海搜索模块。 + +自动执行碧蓝航线的 SOS 深海搜索任务。SOS 任务通过信号列表进入, +每个章节(3~10 章)有独立的搜索信号。通过 OCR 识别剩余信号数量, +循环选择目标章节并执行战役。 + +注意:CN/EN/JP 服已不再有 SOS 地图,任务会自动禁用。 +仅 TW 服仍保留此功能。 + +不同服务器的 UI 布局存在差异(滚动条颜色、章节 OCR 区域等), +通过 @Config.when 装饰器实现服务器特定的适配。 + +配置路径: Sos.Chapter (目标章节) +""" + from campaign.campaign_sos.campaign_base import CampaignBase from module.base.decorator import Config, cached_property from module.base.utils import area_pad, random_rectangle_vector @@ -13,6 +28,22 @@ class CampaignSos(CampaignRun, CampaignBase): + """SOS 深海搜索战役执行器。 + + 继承自 CampaignRun(战役运行)和 CampaignBase(SOS 战役基础), + 负责 SOS 任务的完整自动化流程: + 1. 进入战役页面,打开信号列表 + 2. 通过 OCR 识别剩余信号数量 + 3. 根据配置选择目标章节(3~10 章) + 4. 在信号列表中定位目标章节(不同服务器使用滚动或滑动方式) + 5. 执行 SOS 战役 + 6. 循环直到所有信号用尽 + + 服务器差异: + - EN 服无滚动条,通过拖拽滑动信号列表 + - TW 服滚动条颜色不同(金色 vs 灰色) + - 章节 OCR 的裁剪区域和颜色参数各服务器不同 + """ @cached_property @Config.when(SERVER='en') @@ -69,7 +100,7 @@ def _find_target_chapter(self, chapter): sos_confirm_buttons = TEMPLATE_SIGNAL_CONFIRM.match_multi(self.device.image) all_buttons = sos_goto_buttons + signal_search_buttons + sos_confirm_buttons if not len(all_buttons): - logger.info('No SOS chapter found') + logger.info('未找到SOS章节') return None chapter_buttons = [button.crop(self._sos_chapter_crop) for button in all_buttons] @@ -78,10 +109,10 @@ def _find_target_chapter(self, chapter): if not isinstance(chapter_list, list): chapter_list = [chapter_list] if chapter in chapter_list: - logger.info('Target SOS chapter found') + logger.info('找到目标SOS章节') return all_buttons[chapter_list.index(chapter)] else: - logger.info('Target SOS chapter not found') + logger.info('未找到目标SOS章节') return None @Config.when(SERVER='en') @@ -100,7 +131,7 @@ def _sos_signal_select(self, chapter): Returns: bool: whether select successful """ - logger.hr(f'Select chapter {chapter} signal ') + logger.hr(f'[SOS] 选择第 {chapter} 章信号 ') self.ui_click(SIGNAL_SEARCH_ENTER, appear_button=CAMPAIGN_CHECK, check_button=SIGNAL_LIST_CHECK, skip_first_screenshot=True) @@ -135,7 +166,7 @@ def _sos_signal_select(self, chapter): Returns: bool: whether select successful """ - logger.hr(f'Select chapter {chapter} signal ') + logger.hr(f'[SOS] 选择第 {chapter} 章信号 ') self.ui_click(SIGNAL_SEARCH_ENTER, appear_button=CAMPAIGN_CHECK, check_button=SIGNAL_LIST_CHECK, skip_first_screenshot=True) if chapter in [3, 4, 5]: @@ -145,14 +176,14 @@ def _sos_signal_select(self, chapter): elif chapter in [8, 9, 10]: positions = [1.0, 0.5, 0.0] else: - logger.warning(f'Unknown SOS chapter: {chapter}') + logger.warning(f'[SOS] 未知的SOS章节: {chapter}') positions = [0.0, 0.5, 1.0] for scroll_position in positions: if self._sos_scroll.appear(main=self): self._sos_scroll.set(scroll_position, main=self, distance_check=False) else: - logger.info('SOS signal scroll not appear, skip setting scroll position') + logger.info('SOS信号滚动条未出现,跳过设置滚动位置') target_button = self._find_target_chapter(chapter) if target_button is not None: self._sos_signal_confirm(entrance=target_button) @@ -203,19 +234,19 @@ def run(self, name=None, folder='campaign_sos', mode='normal', total=1): out: page_campaign """ if self.config.SERVER in ['cn', 'en', 'jp']: - logger.warning('AL no longer has SOS maps, disable task') + logger.warning('碧蓝航线不再有SOS地图,禁用任务') self.config.Scheduler_Enable = False self.config.task_stop() - logger.hr('Campaign SOS', level=1) + logger.hr('战役SOS', level=1) self.ui_ensure(page_campaign) while 1: # End remain = OCR_SOS_SIGNAL.ocr(self.device.image) - logger.attr('SOS signal', remain) + logger.attr('SOS信号', remain) if remain <= 0: - logger.info(f'All SOS signals cleared') + logger.info(f'所有SOS信号已清除') break # Run @@ -230,7 +261,7 @@ def run(self, name=None, folder='campaign_sos', mode='normal', total=1): else: self.ui_click(SIGNAL_SEARCH_CLOSE, appear_button=SIGNAL_LIST_CHECK, check_button=CAMPAIGN_CHECK, skip_first_screenshot=True) - logger.warning(f'Failed to clear SOS signals, cannot locate chapter {self.config.Sos_Chapter}') + logger.warning(f'清除SOS信号失败,无法定位章节 {self.config.Sos_Chapter}') break # Scheduler diff --git a/module/statistics/assets.py b/module/statistics/assets.py index 50213574b..a46a1cd3e 100644 --- a/module/statistics/assets.py +++ b/module/statistics/assets.py @@ -1,3 +1,5 @@ +"""统计模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/statistics/azurstats.py b/module/statistics/azurstats.py index f9c135662..bdf28e223 100644 --- a/module/statistics/azurstats.py +++ b/module/statistics/azurstats.py @@ -1,3 +1,14 @@ +"""AzurStats 本地统计与掉落截图管理模块。 + +提供掉落记录(Drop Record)的截图保存、本地解析和数据存储功能。 +支持将战斗掉落截图保存到本地文件系统,并通过 OCR 解析截图中的物品信息 +存入 SQLite 数据库,用于大世界指挥喵 farming 等场景的统计分析。 + +主要组件: + - DropImage: 掉落截图的上下文管理器,用于收集截图并在退出时提交。 + - AzurStats: 统计管理核心类,负责截图保存、本地数据解析和数据库操作。 +""" + import threading import os import sqlite3 @@ -15,13 +26,34 @@ class DropImage: + """掉落截图上下文管理器,用于收集截图并在退出时统一提交。 + + 作为上下文管理器使用(with 语句),在退出时自动调用 AzurStats.commit() + 将收集到的截图进行保存和/或本地解析。 + + Attributes: + stat (AzurStats): 关联的 AzurStats 实例。 + genre (str): 掉落记录的分类标识(如 'opsi_meowfficer_farming')。 + save (bool): 是否保存截图到本地文件系统。 + local (bool): 是否解析截图并存入本地数据库。 + info (str): 附加信息,会追加到保存的文件名中。 + images (list[np.ndarray]): 已收集的截图列表。 + combat_count (int): 战斗记录轮数,用于统计计算。 + + Examples: + >>> with azur_stats.new('opsi_meowfficer_farming') as drop: + ... drop.add(screenshot) + # 退出 with 块时自动提交截图 + """ + def __init__(self, stat, genre, save, local, info=''): """ Args: - stat (AzurStats): - genre: - save: - local: + stat (AzurStats): 关联的 AzurStats 实例。 + genre (str): 掉落记录的分类标识。 + save (bool): 是否保存截图到本地文件系统。 + local (bool): 是否解析截图并存入本地数据库。 + info (str): 附加信息,追加到文件名。 """ self.stat = stat self.genre = str(genre) @@ -81,6 +113,30 @@ def __exit__(self, exc_type, exc_val, exc_tb): class AzurStats: + """AzurStats 统计管理核心类,负责掉落截图的保存、解析和数据存储。 + + 提供两种数据处理路径: + - 远程上传(已废弃):将截图提交到远程 AzurStats 服务。 + - 本地处理:将截图中的物品信息解析后存入 SQLite 数据库, + 并生成统计汇总 CSV 文件(如指挥喵 farming 统计)。 + + 线程安全: + 使用 _local_lock 和 _record_lock 两个线程锁保护数据库写入操作, + 支持多线程并发调用。 + + 类属性: + TIMEOUT (int): 请求超时时间(秒)。 + LOCAL_DB (str): 本地 SQLite 数据库路径。 + LOCAL_MEOW_CSV (str): 指挥喵 farming 统计 CSV 路径。 + LOCAL_GENRES (set): 需要本地处理的记录分类集合。 + + Examples: + >>> stats = AzurStats(config) + >>> with stats.new('opsi_meowfficer_farming') as drop: + ... drop.handle_add(main) + # 退出 with 块时自动提交并解析 + """ + TIMEOUT = 20 LOCAL_DB = './config/azurstats_local.db' LOCAL_MEOW_CSV = './log/azurstat_meowofficer_farming.csv' @@ -284,14 +340,14 @@ def _record_local(self, image, genre, filename, combat_count): try: rows = self._parse_local_opsi_items(image, imgid, genre, combat_count) if not rows: - logger.warning('Local AzurStats parse skipped, no opsi item rows extracted') + logger.warning('本地碧蓝统计解析跳过, no opsi item rows extracted') return False inserted = self._insert_local_opsi_items(rows) self.get_meowofficer_farming() - logger.info(f'Local AzurStats parse success, rows={inserted}') + logger.info(f'本地碧蓝统计解析成功,行数={inserted}') return True except Exception as e: - logger.warning(f'Local AzurStats parse failed, {e}') + logger.warning(f'本地碧蓝统计解析失败, {e}') return False def _save(self, image, genre, filename): @@ -310,7 +366,7 @@ def _save(self, image, genre, filename): os.makedirs(folder, exist_ok=True) file = os.path.join(folder, filename) save_image(image, file) - logger.info(f'Image save success, file: {file}') + logger.info(f'图片保存成功,文件: {file}') return True except Exception as e: logger.exception(e) @@ -349,7 +405,7 @@ def commit(self, images, genre, save=False, local=False, info='', combat_count=0 save_thread.start() if local: - logger.info(f'Local AzurStats parse start, genre={genre}') + logger.info(f'本地碧蓝统计解析开始,类型={genre}') with self._record_lock: self._record_local(image, genre, filename, combat_count) diff --git a/module/statistics/battle_status.py b/module/statistics/battle_status.py index 6b838f8da..4c173070d 100644 --- a/module/statistics/battle_status.py +++ b/module/statistics/battle_status.py @@ -1,3 +1,9 @@ +"""战斗状态统计。 + +从战斗结算截图中通过 OCR 识别敌方舰队名称, +用于掉落统计系统中记录关卡敌人信息。 +""" + from module.base.decorator import cached_property from module.combat.assets import BATTLE_STATUS_S from module.ocr.ocr import Ocr diff --git a/module/statistics/campaign_bonus.py b/module/statistics/campaign_bonus.py index 284510b2c..0cf5c39db 100644 --- a/module/statistics/campaign_bonus.py +++ b/module/statistics/campaign_bonus.py @@ -1,3 +1,10 @@ +"""战役加成统计。 + +识别并解析战役通关后的加成奖励弹窗, +提取额外掉落物品信息并记录到统计数据中。 +继承 GetItemsStatistics 复用物品识别逻辑。 +""" + from module.base.button import ButtonGrid from module.base.utils import * from module.handler.assets import AUTO_SEARCH_MENU_EXIT diff --git a/module/statistics/cl1_database.py b/module/statistics/cl1_database.py index 13b6550af..4c1372716 100644 --- a/module/statistics/cl1_database.py +++ b/module/statistics/cl1_database.py @@ -1,3 +1,9 @@ +"""CL1 数据库模块。 + +使用 SQLite 本地存储战斗统计和掉落数据,支持 AES 加密传输。 +提供设备识别、数据序列化和与 AzurStats 云端同步的功能。 +""" + # -*- coding: utf-8 -*- import sqlite3 import json @@ -928,10 +934,10 @@ def _auto_migrate(self): try: shutil.move(str(old_db_path), str(self.db_path)) logger.info( - f"Moved old CL1 database from {old_db_path} to {self.db_path}" + f"Moved old CL1数据库 from {old_db_path} to {self.db_path}" ) except Exception as e: - logger.error(f"Failed to move old CL1 database: {e}") + logger.error(f"[统计-数据库] 移动旧CL1数据库失败: {e}") if not old_db_dir.exists(): return @@ -945,7 +951,7 @@ def _auto_migrate(self): # logger.info(f"Found legacy data for instance: {instance_dir.name}") self.migrate_from_json(json_file, instance_dir.name) except Exception as e: - logger.error(f"Error during auto migration scan: {e}") + logger.error(f"[统计-数据库] 自动迁移扫描错误: {e}") # ========== 耄耋相接数据记录方法 ========== diff --git a/module/statistics/drop_statistics.py b/module/statistics/drop_statistics.py index a2f419c97..21c63a6ab 100644 --- a/module/statistics/drop_statistics.py +++ b/module/statistics/drop_statistics.py @@ -1,3 +1,16 @@ +"""掉落截图批量解析与统计(Drop Statistics)模块。 + +提供从批量战斗截图中提取掉落物品信息的功能。支持两步工作流: + 1. 从截图中提取物品模板图像(template extraction)。 + 2. 利用模板匹配和 OCR 解析掉落数据,输出为 CSV 文件。 + +典型用法: + 1. 设置 DROP_FOLDER(截图文件夹)和 TEMPLATE_FOLDER(模板文件夹)。 + 2. 调用 extract_template() 提取模板,手动重命名后调用 extract_drop() 导出数据。 + +用于离线分析大量战斗截图的掉落统计(Statistics),不依赖实时设备连接。 +""" + import csv import shutil @@ -15,6 +28,26 @@ class DropStatistics: + """掉落截图批量解析与统计处理器。 + + 从指定文件夹中加载战斗截图,通过模板匹配和 OCR 识别掉落物品, + 并将结果输出为 CSV 文件。支持模板提取和数据导出两种工作模式。 + + 类属性: + DROP_FOLDER (str): 截图根目录,默认 './screenshots'。 + TEMPLATE_FOLDER (str): 模板文件夹名称,相对于 DROP_FOLDER。 + TEMPLATE_BASIC (str): 基础模板资源目录。 + CNOCR_CONTEXT (str): OCR 推理设备,'cpu' 或 'gpu'。 + CSV_FILE (str): 输出 CSV 文件名。 + CSV_OVERWRITE (bool): 是否在导出前覆盖已有 CSV。 + CSV_ENCODING (str): CSV 文件编码,默认 'utf-8'。 + + Examples: + >>> stat = DropStatistics() + >>> stat.extract_template('campaign_13_1') # 步骤 1:提取模板 + >>> stat.extract_drop('campaign_13_1') # 步骤 3:导出掉落数据 + """ + DROP_FOLDER = './screenshots' TEMPLATE_FOLDER = 'item_templates' TEMPLATE_BASIC = './assets/stats_basic' @@ -51,7 +84,7 @@ def csv_overwrite_check(self): """移除已存在的 CSV 文件,此方法仅执行一次。""" if DropStatistics.CSV_OVERWRITE: if os.path.exists(self.csv_file): - logger.info(f'Remove existing csv file: {self.csv_file}') + logger.info(f'移除现有CSV文件: {self.csv_file}') os.remove(self.csv_file) return True @@ -94,7 +127,7 @@ def extract_template(self, campaign): campaign (str): 关卡名称。 """ print('') - logger.hr(f'Extract templates from {campaign}', level=1) + logger.hr(f'提取模板自 {campaign}', level=1) for ts, file in tqdm(load_folder(self.drop_folder(campaign)).items()): try: self.parse_template(file) @@ -103,7 +136,7 @@ def extract_template(self, campaign): continue except Exception as e: logger.exception(e) - logger.warning(f'Error on image {ts}') + logger.warning(f'图像错误 {ts}') continue def extract_drop(self, campaign): @@ -113,7 +146,7 @@ def extract_drop(self, campaign): campaign (str): 关卡名称。 """ print('') - logger.hr(f'extract drops from {campaign}', level=1) + logger.hr(f'提取掉落自 {campaign}', level=1) _ = self.csv_overwrite_check with open(self.csv_file, 'a', newline='', encoding=DropStatistics.CSV_ENCODING) as csv_file: @@ -127,7 +160,7 @@ def extract_drop(self, campaign): continue except Exception as e: logger.exception(e) - logger.warning(f'Error on image {ts}') + logger.warning(f'图像错误 {ts}') continue diff --git a/module/statistics/get_items.py b/module/statistics/get_items.py index 69ffaa64b..132cdb5ef 100644 --- a/module/statistics/get_items.py +++ b/module/statistics/get_items.py @@ -1,3 +1,9 @@ +"""掉落物品统计。 + +从战斗结算的获得物品截图中识别和统计各类掉落物, +支持 1~3 行物品网格的自动检测和数量 OCR 识别。 +""" + from module.combat.assets import * from module.handler.assets import * from module.statistics.assets import GET_ITEMS_ODD diff --git a/module/statistics/item.py b/module/statistics/item.py index cefb25771..4965d9b5e 100644 --- a/module/statistics/item.py +++ b/module/statistics/item.py @@ -1,3 +1,10 @@ +"""掉落物品定义与识别。 + +定义 Item 类用于表示单个掉落物品及其数量, +通过模板匹配和 OCR 从截图中识别物品图标和数量。 +包含物品数量上限校验逻辑。 +""" + import numpy as np import module.config.server as server @@ -58,7 +65,7 @@ def ocr_with_validation(self, image, item_name=None, direct_ocr=False): return amount for retry in range(self.MAX_RETRY): - logger.warning(f'{item_name} amount {amount} exceeds max {max_val}, retry {retry + 1}/{self.MAX_RETRY}') + logger.warning(f'{item_name} amount {amount} 超过最大值 {max_val}, retry {retry + 1}/{self.MAX_RETRY}') result_str = self.cnocr.atomic_ocr_for_single_lines(images, self.alphabet)[0] amount = self.after_process(result_str) if amount <= max_val: @@ -67,7 +74,7 @@ def ocr_with_validation(self, image, item_name=None, direct_ocr=False): if amount > max_val and amount >= 10: truncated = int(str(amount)[:-1]) - logger.warning(f'{item_name} amount {amount} still exceeds max after {self.MAX_RETRY} retries, ' + logger.warning(f'{item_name} amount {amount} still 超过最大值 after {self.MAX_RETRY} retries, ' f'truncating to {truncated}') return truncated @@ -260,7 +267,7 @@ def load_template_folder(self, folder): Args: folder (str): 模板文件夹路径。 """ - logger.info(f'Loading template folder: {folder}') + logger.info(f'加载模板文件夹: {folder}') max_digit = 0 data = load_folder(folder) for name, image in data.items(): @@ -332,7 +339,7 @@ def match_template(self, image, similarity=None): self.next_template_index += 1 name = str(self.next_template_index) - logger.info(f'New template: {name}') + logger.info(f'新模板: {name}') image = crop(image, self.template_area) self.colors[name] = cv2.mean(image)[:3] self.templates[name] = image @@ -460,7 +467,7 @@ def predict(self, image, name=True, amount=True, cost=False, price=False, tag=Fa items = [item for item in self.items if not (price and item.price <= 0)] diff = len(self.items) - len(items) if diff > 0: - logger.warning(f'Ignore {diff} items, because price <= 0') + logger.warning(f'[统计-物品] 忽略 {diff} 个物品,因为价格<=0') self.items = items return self.items diff --git a/module/statistics/opsi_month.py b/module/statistics/opsi_month.py index 61ba85e0f..5d6db9162 100644 --- a/module/statistics/opsi_month.py +++ b/module/statistics/opsi_month.py @@ -1,3 +1,7 @@ +"""大世界(Operation Siren)月度统计模块。 +从加密 SQLite 数据库中读取战斗数据, +计算月度练级效率、资源投入和战斗次数等汇总指标。""" + # 此文件专门用于统计分析大世界(Operation Siren)的月度练级效率与资源投入数据。 # 负责从加密 SQLite 数据库中读取统计数据,并具备计算概况与详细指标的功能。 from __future__ import annotations diff --git a/module/statistics/opsi_runtime.py b/module/statistics/opsi_runtime.py index c765e3d29..00fedbcf7 100644 --- a/module/statistics/opsi_runtime.py +++ b/module/statistics/opsi_runtime.py @@ -45,7 +45,7 @@ def start_battle_timer(config: Any) -> str | None: get_ship_exp_stats(instance_name=instance_name_from_config(config)).on_battle_start() return source except Exception: - logger.debug(f"Failed to start {source} battle timer", exc_info=True) + logger.debug(f"[统计-大世界] 启动 {source} 战斗计时失败", exc_info=True) return None @@ -61,7 +61,7 @@ def finish_battle_timer(config: Any, source: str | None) -> float | None: instance_name=instance_name_from_config(config) ).on_battle_end(source=source) except Exception: - logger.debug(f"Failed to finish {source} battle timer", exc_info=True) + logger.debug(f"[统计-大世界] 结束 {source} 战斗计时失败", exc_info=True) return None @@ -105,7 +105,7 @@ def record_cl1_auto_search_battle( cl1_db.async_increment_battle_count(instance_name) except Exception: - logger.debug("Failed to persist monthly CL1 battle increment", exc_info=True) + logger.debug("[统计-大世界] 持久化月度 CL1 战斗次数失败", exc_info=True) # 侵蚀1两场战斗消耗一次出击,奇数场代表新一轮开始。 # 下一次奇数场到来时,上一轮的完整耗时就能闭合。 @@ -115,13 +115,13 @@ def record_cl1_auto_search_battle( now = time.time() if round_started_at: cost = round(now - float(round_started_at), 2) - logger.attr("CL1 time cost", f"{cost}s/round") + logger.attr("CL1单轮耗时", f"{cost}s/round") try: from module.statistics.ship_exp_stats import get_ship_exp_stats get_ship_exp_stats(instance_name=instance_name).record_round_time(cost) except Exception: - logger.exception("Failed to record cl1 round time") + logger.exception("[统计-大世界] 记录侵蚀1轮次耗时失败") return now @@ -131,7 +131,7 @@ def meow_hazard_level_from_runtime(main: Any) -> int | None: try: hazard_level = getattr(getattr(main, "zone", None), "hazard_level", None) except Exception: - logger.debug("Failed to get hazard level from current zone") + logger.debug("[统计-大世界] 从当前区域获取侵蚀等级失败") if hazard_level not in MEOW_HAZARD_LEVELS: try: @@ -169,22 +169,22 @@ def record_meow_auto_search_battle( cl1_db.async_increment_meow_battle_count(instance_name, hazard_level) except Exception: - logger.debug("Failed to persist monthly meow battle increment", exc_info=True) + logger.debug("[统计-大世界] 持久化月度耄耋战斗次数失败", exc_info=True) now = time.time() if battle_started_at: battle_duration = round(now - float(battle_started_at), 2) if 5 < battle_duration < 600: - logger.attr("Meow battle duration", f"{battle_duration:.1f}s") + logger.attr("耄耋战斗耗时", f"{battle_duration:.1f}s") try: from module.statistics.cl1_database import db as cl1_db cl1_db.async_add_meow_battle_time(instance_name, battle_duration, hazard_level) except Exception: - logger.debug("Failed to record meow battle time", exc_info=True) + logger.debug("[统计-大世界] 记录耄耋战斗耗时失败", exc_info=True) else: logger.debug( - f"Meow battle duration {battle_duration:.1f}s out of range, not recorded" + f"[统计-大世界] 耄耋战斗耗时 {battle_duration:.1f}s 超出范围,不记录" ) return now @@ -194,12 +194,12 @@ def start_meow_search_timer(main: Any) -> tuple[float, int | None]: try: refresh_action_point(main) start_ap = main._action_point_total - logger.debug(f"Meow search started, AP: {start_ap}") + logger.debug(f"[统计-大世界] 耄耋搜索开始,行动力: {start_ap}") except Exception: start_ap = None - logger.debug("Failed to get start action point") + logger.debug("[统计-大世界] 获取起始行动力失败") - logger.debug("Meow search started, timer reset") + logger.debug("[统计-大世界] 耄耋搜索开始,计时器重置") return time.time(), start_ap @@ -212,7 +212,7 @@ def finish_meow_search_timer( try: refresh_action_point(main) except Exception: - logger.debug("Failed to get end action point") + logger.debug("[统计-大世界] 获取结束行动力失败") else: try: record_ap_snapshot( @@ -227,7 +227,7 @@ def finish_meow_search_timer( duration = time.time() - search_started_at hazard_level = meow_hazard_level_from_runtime(main) battles_per_round = meow_battles_per_round(hazard_level) - logger.debug(f"Hazard level: {hazard_level}, battles per round: {battles_per_round}") + logger.debug(f"[统计-大世界] 侵蚀等级: {hazard_level}, 每轮战斗数: {battles_per_round}") # 一次海域搜索可能包含多场战斗,需要折算回单轮耗时。 # WebUI 与调度逻辑都使用这个统一后的轮次单位。 @@ -235,15 +235,15 @@ def finish_meow_search_timer( rounds = battle_count / battles_per_round duration = duration / rounds logger.debug( - f"Meow search total duration: {time.time() - search_started_at:.1f}s, " - f"battles: {battle_count}, rounds: {rounds}, per round: {duration:.1f}s" + f"[统计-大世界] 耄耋搜索总耗时: {time.time() - search_started_at:.1f}s, " + f"战斗: {battle_count}, 轮次: {rounds}, 单轮: {duration:.1f}s" ) if duration < 1 or duration > 1800: - logger.debug(f"Meow search duration {duration:.1f}s out of range, not recorded") + logger.debug(f"[统计-大世界] 耄耋搜索耗时 {duration:.1f}s 超出范围,不记录") return None - logger.attr("Meow search duration", f"{duration:.1f}s") + logger.attr("耄耋搜索耗时", f"{duration:.1f}s") try: from module.statistics.cl1_database import db as cl1_db @@ -253,7 +253,7 @@ def finish_meow_search_timer( hazard_level, ) except Exception: - logger.debug("Failed to record meow search duration", exc_info=True) + logger.debug("[统计-大世界] 记录耄耋搜索耗时失败", exc_info=True) return duration @@ -269,10 +269,10 @@ def record_cl1_akashi_encounter(config: Any) -> int | None: future = cl1_db.async_get_stats(instance_name, month_key) data = future.result(timeout=5.0) encounters = int(data.get("akashi_encounters", 0)) - logger.attr("cl1_akashi_monthly", encounters) + logger.attr("侵蚀1明石月度次数", encounters) return encounters except Exception: - logger.exception("Failed to persist CL1 akashi monthly count") + logger.exception("[统计-大世界] 持久化侵蚀1明石月度次数失败") return None @@ -292,6 +292,6 @@ def record_siren_research_device(main: Any) -> None: hazard_level=hazard_level, ) label = "cl1" if source == "cl1" else f"meow-{hazard_level}" - logger.attr("siren_research_device", label) + logger.attr("塞壬研究装置", label) except Exception: - logger.debug("Failed to record siren research device", exc_info=True) + logger.debug("[统计-大世界] 记录塞壬研究装置失败", exc_info=True) diff --git a/module/statistics/resource_stats.py b/module/statistics/resource_stats.py index 2902516b1..a8e23e242 100644 --- a/module/statistics/resource_stats.py +++ b/module/statistics/resource_stats.py @@ -1,3 +1,7 @@ +"""资源快照统计模块,实现游戏资源的本地记录与历史查询。 +通过 SQLite 数据库存储资源变动快照, +支持按实例和时间范围查询,用于绘制资源趋势图。""" + # 此文件实现了通用资源快照的记录与查询功能。 # 当各项资源数值(如石油、物资、钻石等)发生变化时,记录快照以便后续绘制历史趋势图。 import sqlite3 @@ -99,7 +103,7 @@ def record_resource_snapshot(instance: str, resources: Dict[str, Any]) -> bool: conn.commit() return True except Exception as e: - logger.warning(f'Failed to record resource snapshot: {e}') + logger.warning(f'[统计-资源] 记录资源快照失败: {e}') return False @@ -136,7 +140,7 @@ def get_resource_timeline( result.reverse() return result except Exception as e: - logger.warning(f'Failed to get resource timeline: {e}') + logger.warning(f'[统计-资源] 获取资源时间线失败: {e}') return [] diff --git a/module/statistics/ship_exp_stats.py b/module/statistics/ship_exp_stats.py index 531f87b84..d6a5946a9 100644 --- a/module/statistics/ship_exp_stats.py +++ b/module/statistics/ship_exp_stats.py @@ -1,3 +1,17 @@ +"""舰船经验效率统计模块。 + +统计舰船经验检测数据和战斗时间,包含每日经验效率统计, +用于预估升级时间。 + +功能: +- 记录每次战斗的经验获取量和战斗时间 +- 计算每日经验效率(经验/小时) +- 预估达到目标等级所需时间 +- 持久化统计数据到 JSON 文件 + +继承自 LIST_SHIP_EXP 数据,复用经验数据定义。 +""" + # 此文件用于统计舰船经验检测数据和战斗时间 # 包含每日经验效率统计,用于预估升级时间 @@ -57,7 +71,7 @@ def _load(self) -> dict[str, Any]: return data return {} except Exception as e: - logger.warning(f'Failed to load ship exp data: {e}') + logger.warning(f'[统计-经验] 加载舰船经验数据失败: {e}') return {} def _save(self) -> None: @@ -69,7 +83,7 @@ def _save(self) -> None: encoding='utf-8' ) except Exception as e: - logger.warning(f'Failed to save ship exp data: {e}') + logger.warning(f'[统计-经验] 保存舰船经验数据失败: {e}') # ========== 战斗时间记录 ========== @@ -275,7 +289,7 @@ def save_ship_data( self.data['battle_count_at_check'] = battle_count_at_check self.data['ships'] = ships self._save() - logger.info(f'Ship exp data saved: {len(ships)} ships, target level {target_level}') + logger.info(f'[统计-经验] 舰船经验数据已保存: {len(ships)} 艘舰船,目标等级 {target_level}') def calculate_progress( self, diff --git a/module/statistics/utils.py b/module/statistics/utils.py index e16b35904..2a6769c51 100644 --- a/module/statistics/utils.py +++ b/module/statistics/utils.py @@ -1,3 +1,9 @@ +"""统计工具函数。 + +提供模板图像加载、物品网格(ItemGrid)管理和图像匹配等基础功能, +为掉落统计模块提供底层图像处理支持。 +""" + import os import cv2 diff --git a/module/storage/assets.py b/module/storage/assets.py index 813885957..b764fee1d 100644 --- a/module/storage/assets.py +++ b/module/storage/assets.py @@ -1,3 +1,5 @@ +"""仓库模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/storage/box_disassemble.py b/module/storage/box_disassemble.py index 61976de0f..bb56d68fc 100644 --- a/module/storage/box_disassemble.py +++ b/module/storage/box_disassemble.py @@ -1,3 +1,7 @@ +"""存储箱操作模块,处理装备箱的使用和装备拆解功能。 +支持按稀有度筛选装备箱、设置使用数量, +以及仓库已满时的自动拆解清理。""" + from module.base.timer import Timer from module.base.utils import rgb2gray from module.logger import logger @@ -26,7 +30,7 @@ def _handle_use_box_amount(self, amount): Pages: in: SHOP_BUY_CONFIRM_AMOUNT """ - logger.info(f'Set box amount') + logger.info(f'[存储-拆箱] 设置箱子数量') # 与商店店员逻辑相同的数量输入处理 ocr = Digit(BOX_AMOUNT_OCR, letter=(239, 239, 239), name='OCR_SHOP_AMOUNT') @@ -40,7 +44,7 @@ def _handle_use_box_amount(self, amount): self.appear(AMOUNT_MAX, offset=index_offset): break if timeout.reached(): - logger.warning('Wait AMOUNT_MINUS AMOUNT_PLUS AMOUNT_MAX timeout') + logger.warning('[存储-拆箱] 等待数量按钮超时') break # 等待 OCR 识别到正常数字 @@ -51,11 +55,11 @@ def _handle_use_box_amount(self, amount): if 1 <= current <= amount + 10: break if timeout.reached(): - logger.warning('Wait box amount timeout') + logger.warning('[存储-拆箱] 等待箱子数量超时') break # 设置数量,类似 ui_ensure_index 的逻辑 - logger.info(f'Set box amount: {amount}') + logger.info(f'[存储-拆箱] 设置箱子数量: {amount}') skip_first = True retry = Timer(1, count=2) for _ in self.loop(): @@ -90,7 +94,7 @@ def _check_box_amount(self, button): in: MATERIAL_CHECK out: BOX_USE """ - logger.hr('Check box amount') + logger.hr('[存储-拆箱] 检查箱子数量') amount = 0 ocr = Digit(BOX_REMAIN_AMOUNT_OCR, letter=(229, 227, 3), name='OCR_BOX_REAMIN_AMOUNT') self.interval_clear(MATERIAL_CHECK) @@ -107,7 +111,7 @@ def _check_box_amount(self, button): if amount > 0: break if timeout.reached(): - logger.warning('Wait check box amount timeout') + logger.warning('[存储-拆箱] 等待检查箱子数量超时') break return amount @@ -124,7 +128,7 @@ def _storage_use_multi_box(self, buttons): in: MATERIAL_CHECK out: MATERIAL_CHECK """ - logger.hr('Use multi boxes') + logger.hr('[存储-拆箱] 使用多个箱子') used = 0 end = True for box_button in buttons: @@ -159,12 +163,12 @@ def _storage_use_box_in_page(self, rarity, amount, skip_first_screenshot=False): used = 0 timeout = Timer(1.5, count=3).start() while 1: - logger.attr('Used', f'{used}') + logger.attr('[存储-拆箱] 已使用', f'{used}') if used >= amount: - logger.info('Reached target amount, stop') + logger.info('[存储-拆箱] 达到目标数量,停止') break if timeout.reached(): - logger.info('No more boxes on this page, stop') + logger.info('[存储-拆箱] 此页面没有更多箱子,停止') break if skip_first_screenshot: @@ -184,7 +188,7 @@ def _storage_use_box_in_page(self, rarity, amount, skip_first_screenshot=False): timeout.reset() continue else: - logger.info('No boxes found') + logger.info('[存储-拆箱] 未找到箱子') continue return used @@ -200,7 +204,7 @@ def box_disassemble(self, rarity=1, preserve=2000): in: Any out: page_main """ - logger.hr(f'Disassemble T{rarity} box', level=2) + logger.hr(f'[存储-拆箱] 拆解T{rarity}箱子', level=2) self.box_preserve_amount = preserve self.storage_disassemble_equipment(rarity=rarity, amount=1000000) self.ui_goto_main() @@ -212,7 +216,7 @@ def run(self): in: Any page out: page_main """ - logger.hr('Box disassemble', level=1) + logger.hr('[存储-拆箱] 箱子拆解', level=1) for rarity, box_color in BOX_DISASSEMBLE_DICT.items(): if self.config.__getattribute__(f'BoxDisassemble_Use{box_color}Box'): self.box_disassemble( diff --git a/module/storage/storage.py b/module/storage/storage.py index 55aa39644..fa4622692 100644 --- a/module/storage/storage.py +++ b/module/storage/storage.py @@ -1,3 +1,7 @@ +"""仓库管理核心模块,处理装备和材料仓库的各项操作。 +包括装备识别、物品统计、拆解确认、材料购买数量设置, +以及仓库已满异常处理等功能。""" + import numpy as np from module.base.button import ButtonGrid @@ -74,7 +78,7 @@ def _handle_use_box_amount(self, amount): Pages: in: SHOP_BUY_CONFIRM_AMOUNT """ - logger.info(f'Set box amount') + logger.info(f'[存储] 设置箱子数量') ocr = Digit(BOX_AMOUNT_OCR, letter=(239, 239, 239), name='OCR_SHOP_AMOUNT') index_offset = (40, 50) @@ -86,7 +90,7 @@ def _handle_use_box_amount(self, amount): if self.appear(AMOUNT_MINUS, offset=index_offset) and self.appear(AMOUNT_PLUS, offset=index_offset): break if timeout.reached(): - logger.warning('Wait AMOUNT_MINUS AMOUNT_PLUS timeout') + logger.warning('[存储] 等待数量加减按钮超时') break # 等待 OCR 读取到合理数值 @@ -97,11 +101,11 @@ def _handle_use_box_amount(self, amount): if 1 <= current <= amount + 10: break if timeout.reached(): - logger.warning('Wait box amount timeout') + logger.warning('[存储] 等待箱子数量超时') break # 通过多点击 +/- 按钮设置目标数量,类似 ui_ensure_index - logger.info(f'Set box amount: {amount}') + logger.info(f'[存储] 设置箱子数量: {amount}') skip_first = True retry = Timer(1, count=2) click_count = 0 @@ -114,8 +118,8 @@ def _handle_use_box_amount(self, amount): if diff == 0: break if click_count >= 2: - logger.warning(f'Box amount stuck at {current}, ' - f'requested {amount} but only {current} available') + logger.warning(f'[存储] 箱子数量卡在 {current},' + f'请求 {amount} 但仅有 {current} 可用') break if retry.reached(): @@ -124,7 +128,7 @@ def _handle_use_box_amount(self, amount): click_count += 1 retry.reset() - logger.info(f'Box amount set to {current}') + logger.info(f'[存储] 箱子数量设置为 {current}') return current def _storage_use_one_box(self, button, amount=1): @@ -147,7 +151,7 @@ def _storage_use_one_box(self, button, amount=1): in: MATERIAL_CHECK out: MATERIAL_CHECK """ - logger.hr('Use one box') + logger.hr('[存储] 使用一个箱子') success = False used = 0 self.interval_clear([ @@ -205,13 +209,13 @@ def _storage_use_one_box(self, button, amount=1): # 仓库已满处理 if self.appear(EQUIPMENT_FULL, offset=(20, 20)): - logger.info('Storage full') + logger.info('仓库已满') # 关闭弹窗后抛出异常 self.ui_click(MATERIAL_ENTER, check_button=self._storage_in_material, appear_button=EQUIPMENT_FULL, retry_wait=3, skip_first_screenshot=True) raise StorageFull - logger.info(f'Used {used} box(es)') + logger.info(f'[存储] 使用了 {used} 个箱子') return used def _storage_use_box_in_page(self, rarity, amount, skip_first_screenshot=False): @@ -235,12 +239,12 @@ def _storage_use_box_in_page(self, rarity, amount, skip_first_screenshot=False): used = 0 timeout = Timer(1.5, count=3).start() while 1: - logger.attr('Used', f'{used}/{amount}') + logger.attr('[存储] 已使用', f'{used}/{amount}') if used >= amount: - logger.info('Reached target amount, stop') + logger.info('达到目标数量,停止') break if timeout.reached(): - logger.info('No more boxes on this page, stop') + logger.info('此页面没有更多箱子,停止') break if skip_first_screenshot: @@ -254,7 +258,7 @@ def _storage_use_box_in_page(self, rarity, amount, skip_first_screenshot=False): used += self._storage_use_one_box(box_button, amount) continue else: - logger.info('No boxes found') + logger.info('未找到箱子') continue return used @@ -279,7 +283,7 @@ def _storage_use_box_execute(self, rarity=1, amount=10): in: page_storage, material, MATERIAL_CHECK out: page_storage, material, MATERIAL_CHECK """ - logger.hr('Use Box', level=2) + logger.hr('使用箱子', level=2) used = 0 if MATERIAL_SCROLL.appear(main=self): @@ -290,16 +294,16 @@ def _storage_use_box_execute(self, rarity=1, amount=10): MATERIAL_SCROLL.set_top(main=self) while 1: - logger.hr('Use boxes in page') + logger.hr('使用页面中的箱子') used += self._storage_use_box_in_page(rarity=rarity, amount=max(amount - used, 0)) if used >= amount: break if MATERIAL_SCROLL.at_bottom(main=self): - logger.info('Scroll bar reached end, stop') + logger.info('[仓库] 滚动条到底,停止') break MATERIAL_SCROLL.next_page(main=self) else: - logger.hr('Use boxes in page') + logger.hr('使用页面中的箱子') used += self._storage_use_box_in_page(rarity=rarity, amount=amount) return used @@ -329,7 +333,7 @@ def _storage_disassemble_equipment_execute_once(self, amount=40): GET_ITEMS_2, DISASSEMBLE_CANCEL, ]) - logger.info(f'Disassemble once, expected amount: {amount}') + logger.info(f'[仓库] 拆解一次, 预期数量: {amount}') for _ in self.loop(): if self.appear(GET_ITEMS_1, offset=(5, 5), interval=3): @@ -352,7 +356,7 @@ def _storage_disassemble_equipment_execute_once(self, amount=40): items = EQUIPMENT_ITEMS.predict(self.device.image, name=False, amount=True) if not len(items): - logger.warning('No items in storage to disassemble') + logger.warning('[仓库] 仓库中没有可拆解的物品') return 0 cumsum = np.cumsum([item.amount for item in items]) for item, total in zip(items, cumsum): @@ -366,25 +370,25 @@ def _storage_disassemble_equipment_execute_once(self, amount=40): amount = min(cumsum[-1], amount) # 等待装备被选中 - logger.info(f'Disassemble once, in_storage amount: {amount}') + logger.info(f'[仓库] 拆解一次, 仓库数量: {amount}') timeout = Timer(1, count=2).start() prev_disassemble = 0 while 1: self.device.screenshot() disassembled = OCR_DISASSEMBLE_COUNT.ocr(self.device.image) if disassembled >= amount: - logger.info('Disassemble amount reached expected amount') + logger.info('[仓库] 拆解数量达到预期') break if timeout.reached(): - logger.warning('Wait disassemble amount timeout') + logger.warning('[仓库] 等待拆解数量超时') break if disassembled > prev_disassemble: prev_disassemble = disassembled timeout.reset() - logger.info(f'Disassemble once, actual amount: {disassembled}') + logger.info(f'[仓库] 拆解一次, 实际数量: {disassembled}') if disassembled <= 0: - logger.warning('No items selected to disassemble') + logger.warning('[仓库] 未选择物品进行拆解') return 0 skip_first_screenshot = True @@ -398,7 +402,7 @@ def _storage_disassemble_equipment_execute_once(self, amount=40): if click_count >= 3: # 可能是因为没有选中装备, # _storage_disassemble_equipment_execute() 会重新选取 - logger.warning('Failed to confirm disassemble after 3 trial') + logger.warning('[仓库] 尝试3次后仍无法确认拆解') disassembled = 0 break if success and self.appear(DISASSEMBLE_CANCEL, offset=(20, 20)): @@ -449,13 +453,13 @@ def _storage_disassemble_equipment_execute(self, rarity=1, amount=40): MATERIAL_SCROLL.set_top(main=self) while 1: - logger.hr('Disassemble once') - logger.attr('Disassembled', f'{disassembled}/{amount}') + logger.hr('拆解一次') + logger.attr('已拆解', f'{disassembled}/{amount}') if self.appear(EQUIPMENT_EMPTY, offset=(20, 20)): - logger.info('Equipment list empty, stop') + logger.info('[仓库] 装备列表为空,停止') break if disassembled >= amount: - logger.info('Reached target amount, stop') + logger.info('达到目标数量,停止') break if amount - disassembled < 40: @@ -483,21 +487,21 @@ def storage_disassemble_equipment(self, rarity=1, amount=15): in: Any out: page_storage, equipment, DISASSEMBLE """ - logger.hr('Disassemble Equipment', level=2) + logger.hr('拆解装备', level=2) self.ui_goto_storage() # 装备中开关不影响拆解,无需设置;筛选确认会自动等待仓库稳定 disassembled = 0 while 1: - logger.attr('Total_Disassemble', f'{disassembled}/{amount}') + logger.attr('总拆解数', f'{disassembled}/{amount}') if disassembled >= amount: - logger.info('Reached total target amount, stop') + logger.info('[仓库] 达到总目标数量,停止') break self._storage_enter_material() try: boxes = self._storage_use_box_execute(rarity=rarity, amount=amount - disassembled) if boxes <= 0: - logger.warning('No more boxes to use, disassemble equipment end') + logger.warning('[仓库] 没有更多箱子可使用,拆解装备结束') self.storage_has_boxes = False break # 2025.05.20 起箱中装备会自动拆解 @@ -511,10 +515,10 @@ def storage_disassemble_equipment(self, rarity=1, amount=15): equip = self._storage_disassemble_equipment_execute(rarity=rarity, amount=amount) disassembled += equip if equip <= 0: - logger.warning('StorageFull but unable to disassemble, ' - 'probably because storage is full of rare equipments or above, ' - 'disassemble equipment end') - logger.warning('Please manually disassemble some equipments to free up storage') + logger.warning('[存储-仓库] 仓库已满但无法拆解,' + '可能是因为仓库全是稀有及以上装备,' + '拆解装备结束') + logger.warning('[仓库] 请手动拆解一些装备以释放仓库空间') self.storage_has_boxes = False break @@ -537,7 +541,7 @@ def storage_use_box(self, rarity=1, amount=40): in: Any out: page_storage, material, MATERIAL_CHECK """ - logger.hr('Use boxes', level=2) + logger.hr('使用箱子', level=2) self.ui_goto_storage() self._storage_enter_material() self._wait_until_storage_stable() @@ -547,9 +551,9 @@ def storage_use_box(self, rarity=1, amount=40): self._storage_enter_disassemble() self._storage_disassemble_equipment_execute(rarity=rarity, amount=amount) - logger.attr('Total_Used', f'{used}/{amount}') + logger.attr('总使用数', f'{used}/{amount}') if used >= amount: - logger.info('Reached total target amount, stop') + logger.info('[仓库] 达到总目标数量,停止') break boxes = 0 @@ -558,15 +562,15 @@ def storage_use_box(self, rarity=1, amount=40): boxes = self._storage_use_box_execute(rarity=rarity, amount=amount - used) used += boxes if boxes <= 0: - logger.warning('No more boxes to use, use boxes end') + logger.warning('[仓库] 没有更多箱子可使用,使用箱子结束') self.storage_has_boxes = False break except StorageFull: if boxes <= 0: - logger.warning('Unable to use boxes because storage full, ' - 'probably because storage is full of rare equipments or above, ' - 'use boxes end') - logger.warning('Please manually disassemble some equipments to free up storage') + logger.warning('[存储-仓库] 无法使用箱子因为仓库已满,' + '可能是因为仓库全是稀有及以上装备,' + '使用箱子结束') + logger.warning('[仓库] 请手动拆解一些装备以释放仓库空间') self.storage_has_boxes = False break @@ -593,11 +597,11 @@ def handle_storage_full(self, rarity=1, amount=40): return False # 检测到 EQUIPMENT_FULL 弹窗,进入拆解流程 - logger.info('handle_storage_full') + logger.info('[仓库] 处理仓库已满') self.ui_click(EQUIPMENT_FULL, check_button=DISASSEMBLE_CANCEL, skip_first_screenshot=True, retry_wait=3) disassembled = self._storage_disassemble_equipment_execute(rarity=rarity, amount=amount) if disassembled <= 0: - logger.warning('Storage full but unable to disassemble any equipment') + logger.warning('[仓库] 仓库已满但无法拆解任何装备') # 退出拆解页面,返回之前的页面 skip_first_screenshot = True diff --git a/module/storage/ui.py b/module/storage/ui.py index 66bbacf2c..f086462c8 100644 --- a/module/storage/ui.py +++ b/module/storage/ui.py @@ -1,3 +1,7 @@ +"""仓库 UI 导航模块,处理仓库页面的页面切换和筛选设置。 +包括装备栏和材料栏的切换导航, +以及稀有度等筛选条件的配置。""" + from module.base.button import ButtonGrid from module.base.decorator import cached_property from module.combat.assets import GET_ITEMS_1, GET_ITEMS_2 @@ -49,7 +53,7 @@ def _storage_enter_material(self, skip_first_screenshot=True): in: page_storage, any out: page_storage, material, MATERIAL_CHECK """ - logger.info('storage enter material') + logger.info('仓库进入材料') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -65,13 +69,13 @@ def _storage_enter_material(self, skip_first_screenshot=True): continue # equipment -> material if self.appear(DISASSEMBLE, offset=(20, 20), interval=3): - logger.info('DISASSEMBLE -> MATERIAL_ENTER') + logger.info('[存储-UI] 拆解 -> 材料进入') self.device.click(MATERIAL_ENTER) self.interval_reset(STORAGE_CHECK) continue # design -> material if self.appear(STORAGE_CHECK, offset=(20, 20), interval=3): - logger.info('DISASSEMBLE -> MATERIAL_ENTER') + logger.info('[存储-UI] 拆解 -> 材料进入') self.device.click(MATERIAL_ENTER) continue @@ -83,7 +87,7 @@ def _storage_enter_equipment(self, skip_first_screenshot=True): in: page_storage, any out: page_storage, equipment, DISASSEMBLE """ - logger.info('storage enter equipment') + logger.info('仓库进入装备') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -105,7 +109,7 @@ def _storage_enter_equipment(self, skip_first_screenshot=True): continue # design -> equipment if self.appear(STORAGE_CHECK, offset=(20, 20), interval=3): - logger.info('STORAGE_CHECK -> EQUIPMENT_ENTER') + logger.info('[存储-UI] 存储检查 -> 装备进入') self.device.click(EQUIPMENT_ENTER) continue @@ -117,7 +121,7 @@ def _storage_enter_disassemble(self, skip_first_screenshot=True): in: page_storage, any out: page_storage, disassemble, DISASSEMBLE_CANCEL """ - logger.info('storage enter disassemble') + logger.info('仓库进入拆解') self.appear(STORAGE_CHECK, interval=3) while 1: if skip_first_screenshot: @@ -141,14 +145,14 @@ def _storage_enter_disassemble(self, skip_first_screenshot=True): continue # design -> equipment if self.appear(STORAGE_CHECK, offset=(20, 20), interval=3): - logger.info('STORAGE_CHECK -> EQUIPMENT_ENTER') + logger.info('[存储-UI] 存储检查 -> 装备进入') self.device.click(EQUIPMENT_ENTER) continue self.interval_clear(STORAGE_CHECK) def _equipment_filter_enter(self): - logger.info('Equipment filter enter') + logger.info('装备筛选进入') self.interval_clear(STORAGE_CHECK) for _ in self.loop(): if self.appear(EQUIPMENT_FILTER_CONFIRM, offset=(20, 20)): @@ -166,7 +170,7 @@ def _equipment_filter_enter(self): continue def _equipment_filter_confirm(self): - logger.info('Equipment filter confirm') + logger.info('装备筛选确认') self.interval_clear(EQUIPMENT_FILTER_CONFIRM) self.ui_click(EQUIPMENT_FILTER_CONFIRM, check_button=STORAGE_CHECK, skip_first_screenshot=True) self._wait_until_storage_stable() diff --git a/module/submodule/submodule.py b/module/submodule/submodule.py index c062a8d33..4589e6c39 100644 --- a/module/submodule/submodule.py +++ b/module/submodule/submodule.py @@ -1,3 +1,7 @@ +"""子模块加载器,通过 importlib 动态加载外部桥接模块。 +支持加载 MAA、FPY 等第三方战斗辅助模块, +并提供模块配置的加载接口。""" + import importlib from module.logger import logger diff --git a/module/submodule/utils.py b/module/submodule/utils.py index 322fe642c..cdc27d421 100644 --- a/module/submodule/utils.py +++ b/module/submodule/utils.py @@ -1,3 +1,7 @@ +"""子模块工具函数,定义外部桥接模块的注册表和映射关系。 +维护可用功能列表、模块名称字典, +以及各功能到对应子模块的映射配置。""" + import os MOD_DICT = { diff --git a/module/tactical/assets.py b/module/tactical/assets.py index ea367189e..c7abdfb4b 100644 --- a/module/tactical/assets.py +++ b/module/tactical/assets.py @@ -1,3 +1,5 @@ +"""战术学院模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/tactical/tactical_class.py b/module/tactical/tactical_class.py index 40125720f..f1a07f00b 100644 --- a/module/tactical/tactical_class.py +++ b/module/tactical/tactical_class.py @@ -1,3 +1,20 @@ +"""战术学院模块。 + +自动管理碧蓝航线战术学院的教材使用和技能学习。主要功能: +- 收取已完成的战术学院奖励 +- 为舰娘选择合适的技能进行学习 +- 根据配置过滤器选择最优教材(颜色、等级、经验加成) +- 处理经验溢出控制,避免教材浪费 +- 技能满级时自动切换到下一个未满级技能 +- 支持急速训练功能 + +教材按颜色分为三类:红色(攻击)、蓝色(防御)、黄色(辅助), +按等级分为 T1~T4 四个品质。同类型教材可获得 1.5 倍经验加成。 + +配置路径: Tactical.TacticalFilter (教材过滤), Tactical.SkillAutoSwitch (技能自动切换), + AddNewStudent.Enable (自动添加学员) +""" + import module.config.server as server from module.base.button import Button, ButtonGrid from module.base.filter import Filter @@ -61,14 +78,14 @@ def after_process(self, result): if result.endswith("580"): new = f'{result[:-3]}5800' - logger.info(f'ExpOnBookSelect result {result} is revised to {new}') + logger.info(f'[战术-经验] 教材选择经验结果 {result} 修正为 {new}') result = new if '/' not in result: for exp in [5800, 4400, 3200, 2200, 1400, 800, 400, 200, 100]: if res := re.match(rf'^(\d+){exp}$', result): # 10005800 -> 1000/5800 new = f'{res.group(1)}/{exp}' - logger.info(f'ExpOnBookSelect result {result} is revised to {new}') + logger.info(f'[战术-经验] 教材选择经验结果 {result} 修正为 {new}') result = new break @@ -194,6 +211,23 @@ def __str__(self): class RewardTacticalClass(Dock): + """战术学院奖励收取和教材管理器。 + + 继承自 Dock(船坞操作),负责战术学院的完整自动化流程: + 1. 进入战术学院页面,收取已完成的技能学习奖励 + 2. 检测技能是否满级,满级时自动切换到下一个技能 + 3. 根据过滤器配置从可用教材中选择最优教材 + 4. 处理经验溢出,避免在技能即将升级时浪费高阶教材 + 5. 从船坞中选择合适的舰娘开始新的技能学习 + + 流程入口为 tactical_class_receive(),run() 方法为任务调度器的标准入口。 + + Attributes: + books: 当前可用的教材列表(SelectedGrids)。 + tactical_finish: 各技能槽位的预计完成时间列表。 + dock_select_index: 船坞中当前选中舰船的索引,用于跳过 META 舰船。 + """ + books: SelectedGrids tactical_finish = [] dock_select_index = 0 @@ -220,13 +254,13 @@ def _tactical_books_get(self, skip_first_screenshot=True): self.handle_info_bar() # 在启航典礼委托中获得舰船时会出现 info_bar if not self.appear(TACTICAL_CLASS_START, offset=(30, 30)): - logger.info('Not in TACTICAL_CLASS_START anymore, exit') + logger.info('[战术-教材] 不再在教材选择界面,退出') return False books = SelectedGrids([Book(self.device.image, button) for button in BOOKS_GRID.buttons]).select(valid=True) self.books = books - logger.attr('Book_count', books.count) - logger.attr('Books', str(books)) + logger.attr('教材数量', books.count) + logger.attr('教材列表', str(books)) # End if books and books.count == prev.count: @@ -235,8 +269,8 @@ def _tactical_books_get(self, skip_first_screenshot=True): if n % 3 == 0: self.device.sleep(3) - logger.warning('No book found.') - raise ScriptError('No book found, after 15 attempts.') + logger.warning('[战术-教材] 未找到教材') + raise ScriptError('[战术-教材] 尝试15次后仍未找到教材') def _tactical_book_select(self, book, skip_first_screenshot=True): """ @@ -246,7 +280,7 @@ def _tactical_book_select(self, book, skip_first_screenshot=True): book (Book): 目标教材对象 skip_first_screenshot (bool): 是否跳过首次截图 """ - logger.info(f'Book select {book}') + logger.info(f'[战术-教材] 选择教材 {book}') interval = Timer(2, count=6) while 1: if skip_first_screenshot: @@ -273,9 +307,8 @@ def _tactical_books_filter_exp(self): # 即将达到10级满级,需要移除特定教材以防止经验浪费 if total == 5800: - logger.info('About to reach level 10; will remove ' - 'detected books based on actual ' - f'progress: {current}/{total}; {remain}') + logger.info('[战术-溢出] 即将达到10级满级,将根据实际进度移除教材: ' + f'{current}/{total}; {remain}') def filter_exp_func(book): # 保留至少一本非 T1 的加成教材(如果别无选择) @@ -292,8 +325,8 @@ def filter_exp_func(book): before = self.books.count self.books = SelectedGrids([book for book in self.books if filter_exp_func(book)]) - logger.attr('Filtered', before - self.books.count) - logger.attr('Books', str(self.books)) + logger.attr('过滤数量', before - self.books.count) + logger.attr('教材列表', str(self.books)) def _is_current_skill_max(self, skip_first_screenshot=True): """ @@ -308,10 +341,10 @@ def _is_current_skill_max(self, skip_first_screenshot=True): try: current, _, total = SKILL_EXP.ocr(self.device.image) if total > 0 and current >= total: - logger.info(f'Current skill is max level: {current}/{total}') + logger.info(f'[战术-技能] 当前技能已满级: {current}/{total}') return True except Exception as e: - logger.warning(f'Failed to check skill max level: {e}') + logger.warning(f'[战术-技能] 检查技能满级失败: {e}') return False def _wait_until_appear(self, button, offset, attempts=5): @@ -340,33 +373,33 @@ def _try_switch_to_next_skill(self): in: TACTICAL_CLASS_START (点击取消后进入 SKILL_CONFIRM) out: TACTICAL_CLASS_START (if success) or SKILL_CONFIRM (if no skill found) """ - logger.hr('Try switch to next skill', level=2) + logger.hr('尝试切换到下一个技能', level=2) # 取消当前教材选择,回到技能选择界面 self.device.click(TACTICAL_CLASS_CANCEL) self.device.sleep((0.5, 1.0)) # 等待技能选择界面加载 if not self._wait_until_appear(SKILL_CONFIRM, offset=(20, 20)): - logger.warning('Failed to return to SKILL_CONFIRM after cancel') + logger.warning('[战术-切换] 取消后无法返回技能确认界面') return False # 寻找下一个非满级技能 selected_skill = self.find_not_full_level_skill(skip_first_screenshot=True) if selected_skill is None: - logger.info('No other non-max skill available for this ship, return to tactical page') + logger.info('[战术-切换] 该舰娘没有其他非满级技能,返回战术页面') self._return_to_tactical_page() return False # 选中并确认新技能 - logger.info('Switching to next non-max skill') + logger.info('[战术-切换] 切换到下一个非满级技能') self._tactical_skill_select(selected_skill) self.device.click(SKILL_CONFIRM) # 等待教材选择界面加载 if self._wait_until_appear(TACTICAL_CLASS_START, offset=(30, 30)): - logger.info('Entered TACTICAL_CLASS_START after skill switch') + logger.info('[战术-切换] 技能切换后进入教材选择界面') return True - logger.warning('Failed to enter TACTICAL_CLASS_START after skill switch') + logger.warning('[战术-切换] 技能切换后无法进入教材选择界面') self._return_to_tactical_page() return False @@ -381,7 +414,7 @@ def _tactical_books_choose(self): in: TACTICAL_CLASS_START out: Unknown, may TACTICAL_CLASS_START, page_tactical, or _tactical_animation_running """ - logger.hr('Tactical books choose', level=2) + logger.hr('选择战术教材', level=2) MAX_SWITCH_RETRIES = 3 for retry in range(MAX_SWITCH_RETRIES + 1): if not self._tactical_books_get(): @@ -399,7 +432,7 @@ def _tactical_books_choose(self): # 应用配置过滤器,不修改 self.books BOOK_FILTER.load(self.config.Tactical_TacticalFilter) books = BOOK_FILTER.apply(self.books.grids) - logger.attr('Book_sort', ' > '.join([str(book) for book in books])) + logger.attr('教材排序', ' > '.join([str(book) for book in books])) # 如果有可用教材则选择,否则检测是否因为技能已满级 if not books: @@ -407,28 +440,28 @@ def _tactical_books_choose(self): if not self.config.Tactical_SkillAutoSwitch: break if retry >= MAX_SWITCH_RETRIES: - logger.warning('Max skill switch retries reached') + logger.warning('[战术-选择] 达到技能切换最大重试次数') break if not self._is_current_skill_max(skip_first_screenshot=True): break - logger.info('No books because skill is max, try switch to next skill') + logger.info('[战术-选择] 没有教材因为技能已满级,尝试切换到下一个技能') if not self._try_switch_to_next_skill(): break - logger.info('Switched to next skill, re-enter book choose') + logger.info('[战术-选择] 已切换到下一个技能,重新进入教材选择') continue book = books[0] if str(book) != 'first': self._tactical_book_select(book) else: - logger.info('Choose first book') + logger.info('[战术-选择] 选择第一本教材') self._tactical_book_select(first) - logger.info(f'_tactical_books_choose -> {TACTICAL_CLASS_START}') + logger.info(f'[战术-选择] 点击开始课程 {TACTICAL_CLASS_START}') self.device.click(TACTICAL_CLASS_START) return True - logger.info('Cancel tactical') - logger.info(f'_tactical_books_choose -> {TACTICAL_CLASS_CANCEL}') + logger.info('[战术-选择] 取消战术') + logger.info(f'[战术-选择] 点击取消 {TACTICAL_CLASS_CANCEL}') self.device.click(TACTICAL_CLASS_CANCEL) return True @@ -463,12 +496,12 @@ def handle_rapid_training(self): def _tactical_get_finish(self): """获取战术学院的完成时间。""" - logger.hr('Tactical get finish') + logger.hr('获取战术完成时间') grids = ButtonGrid( origin=(421, 596), delta=(223, 0), button_shape=(139, 27), grid_shape=(4, 1), name='TACTICAL_REMAIN') is_running = [self.image_color_count(button, color=(148, 255, 99), count=50) for button in grids.buttons] - logger.info(f'Tactical status: {["running" if s else "empty" for s in is_running]}') + logger.info(f'[战术-状态] 战术状态: {["运行中" if s else "空闲" for s in is_running]}') buttons = [b for b, s in zip(grids.buttons, is_running) if s] ocr = Duration(buttons, letter=(148, 255, 99), name='TACTICAL_REMAIN') @@ -477,7 +510,7 @@ def _tactical_get_finish(self): now = current_time() self.tactical_finish = [(now + remain).replace(microsecond=0) for remain in remains if remain.total_seconds()] - logger.info(f'Tactical finish: {[str(f) for f in self.tactical_finish]}') + logger.info(f'[战术-完成] 战术完成时间: {[str(f) for f in self.tactical_finish]}') return self.tactical_finish def _handle_tactical_add_new_student(self, study_finished): @@ -568,7 +601,7 @@ def _handle_tactical_dock(self): # When you click a ship from page_main -> dock, # this ship will be selected default in tactical dock, # so we need click BACK_ARROW to clear selected state - logger.info('Having pre-selected ship in dock, re-enter') + logger.info('[战术-船坞] 船坞中有预选舰船,重新进入') self.device.click(BACK_ARROW) self.interval_reset([BOOK_EMPTY_POPUP, DOCK_CHECK], interval=3) return True, False @@ -576,7 +609,7 @@ def _handle_tactical_dock(self): study_finished = False # If not enable or can not fina a suitable ship if not self.config.AddNewStudent_Enable: - logger.info('Not going to learn skill but in dock, close it') + logger.info('[战术-船坞] 不学习技能但在船坞中,关闭') study_finished = True self.device.click(BACK_ARROW) elif not self.select_suitable_ship(): @@ -598,7 +631,7 @@ def _handle_tactical_skill_confirm(self, pending_skill_auto_switch): study_finished = True self.device.click(BACK_ARROW) else: - logger.info('Not going to learn skill but having SKILL_CONFIRM, close it') + logger.info('[战术-技能] 不学习技能但有技能确认界面,关闭') study_finished = True self.device.click(BACK_ARROW) self.interval_reset([BOOK_EMPTY_POPUP, SKILL_CONFIRM], interval=3) @@ -608,7 +641,7 @@ def _handle_tactical_meta(self): if not self.appear(TACTICAL_META, offset=(200, 20), interval=3): return False - logger.info('META skill found, exit') + logger.info('[战术-META] 发现META技能,退出') self.device.click(BACK_ARROW) # Select the next ship in `select_suitable_ship()` self.dock_select_index += 1 @@ -638,7 +671,7 @@ def tactical_class_receive(self, skip_first_screenshot=True): in: page_reward, TACTICAL_CLASS_START out: page_reward """ - logger.hr('Tactical class receive', level=1) + logger.hr('领取战术学院奖励', level=1) received = False study_finished = not self.config.AddNewStudent_Enable pending_skill_auto_switch = False @@ -701,9 +734,9 @@ def tactical_class_receive(self, skip_first_screenshot=True): continue if book_empty: - logger.warning('Tactical books empty, delay to tomorrow') + logger.warning('[战术-教材] 教材为空,延迟到明天') self.tactical_finish = get_server_next_update(self.config.Scheduler_ServerUpdate) - logger.info(f'Tactical finish: {self.tactical_finish}') + logger.info(f'[战术-完成] 战术完成时间: {self.tactical_finish}') return True def _tactical_skill_select(self, selected_skill, skip_first_screenshot=True): @@ -714,7 +747,7 @@ def _tactical_skill_select(self, selected_skill, skip_first_screenshot=True): selected_skill: 目标技能的 Button 对象 skip_first_screenshot (bool): 是否跳过首次截图 """ - logger.info('Tactical skill select') + logger.info('[战术-技能] 选择技能') while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -745,12 +778,12 @@ def _tactical_skill_choose(self): in: SKILL_CONFIRM out: Unknown, may TACTICAL_CLASS_START, page_tactical """ - logger.hr('Tactical skill choose') + logger.hr('选择战术技能') selected_skill = self.find_not_full_level_skill() # 找不到可用技能,认为该舰船无需学习 if selected_skill is None: - logger.info('No available skill to learn') + logger.info('[战术-技能] 没有可用技能可学习') return False # 选中技能说明未满级,应开始或继续学习 @@ -761,7 +794,7 @@ def _tactical_skill_choose(self): return True def select_suitable_ship(self): - logger.hr('Select suitable ship') + logger.hr('选择合适舰船') # 根据配置设置收藏筛选 self.dock_favourite_set(enable=self.config.AddNewStudent_Favorite, wait_loading=False) @@ -773,7 +806,7 @@ def select_suitable_ship(self): # 船坞中没有舰船 if self.appear(DOCK_EMPTY, offset=(30, 30)): - logger.info('Dock is empty or favorite ships is empty') + logger.info('[战术-船坞] 船坞为空或收藏舰船为空') return False # 舰船卡片加载可能较慢,例如: @@ -790,16 +823,16 @@ def select_suitable_ship(self): if first_empty >= first_ship: break else: - logger.warning('Wait ship cards timeout') + logger.warning('[战术-船坞] 等待舰船卡片超时') try: min_level = int(self.config.AddNewStudent_MinLevel) if min_level < 1: min_level = 1 except (ValueError, TypeError) as e: - logger.warning(f'Invalid AddNewStudent_MinLevel: {self.config.AddNewStudent_MinLevel}, {e}') + logger.warning(f'[战术-船坞] 无效的最低等级配置: {self.config.AddNewStudent_MinLevel}, {e}') min_level = 1 - logger.attr('AddNewStudent_MinLevel', min_level) + logger.attr('最低等级要求', min_level) should_select_button = None for button, level in list(zip(CARD_GRIDS.buttons, list_level))[self.dock_select_index:]: @@ -809,7 +842,7 @@ def select_suitable_ship(self): break if should_select_button is None: - logger.info(f'No ships with level >= {min_level} in dock') + logger.info(f'[战术-船坞] 船坞中没有等级 >= {min_level} 的舰船') return False # 选择舰船 @@ -862,7 +895,7 @@ def find_not_full_level_skill(self, skip_first_screenshot=True): # ['NEXT:MA', 'NEXT:/1D]', 'NEXT:MA'](实际:`NEXT:MAX, NEXT:0/100, NEXT:MAX`) # ['NEXT:MA', 'NEX T:/ 14[]]', 'NEXT:MA'](实际:`NEXT:MAX, NEXT:150/1400, NEXT:MAX`) if 'MA' not in level: - logger.attr('LEVEL', 'EMPTY' if len(level) == 0 else level) + logger.attr('等级', 'EMPTY' if len(level) == 0 else level) return skill_button return None @@ -882,5 +915,5 @@ def run(self): if self.tactical_finish: self.config.task_delay(target=self.tactical_finish) else: - logger.info('No tactical running') + logger.info('[战术-学院] 没有战术课程在运行') self.config.task_delay(success=False) diff --git a/module/template/assets.py b/module/template/assets.py index 2082ebe53..3ce7b8eda 100644 --- a/module/template/assets.py +++ b/module/template/assets.py @@ -1,3 +1,5 @@ +"""模板模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/ui/assets.py b/module/ui/assets.py index 5a18dd6d3..9f36b556c 100644 --- a/module/ui/assets.py +++ b/module/ui/assets.py @@ -1,3 +1,5 @@ +"""UI模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/ui/navbar.py b/module/ui/navbar.py index 6d519ebbe..eee8a5baf 100644 --- a/module/ui/navbar.py +++ b/module/ui/navbar.py @@ -1,3 +1,6 @@ +"""页面内标签导航栏模块。定义 Navbar 类,通过颜色检测判断标签的激活/非激活状态, +支持自动切换到指定标签页。""" + from module.base.base import ModuleBase from module.base.button import ButtonGrid from module.base.timer import Timer @@ -82,11 +85,11 @@ def get_info(self, main): elif len(active) == 1: active = active[0] else: - logger.warning(f'Too many active nav items found in {self.name}, items: {active}') + logger.warning(f'发现过多激活的导航项: {self.name}, items: {active}') active = active[0] if len(total) < 2: - logger.warning(f'Too few nav items found in {self.name}, items: {total}') + logger.warning(f'发现过少的导航项: {self.name}, items: {total}') if len(total) == 0: left, right = None, None else: @@ -164,7 +167,7 @@ def set(self, main, left=None, right=None, upper=None, bottom=None, skip_first_s bool: 是否设置成功。 """ if left is None and right is None and upper is None and bottom is None: - logger.warning('Invalid index to set, must set an index from 1 direction') + logger.warning('[UI-导航栏] 设置索引无效,必须指定一个方向的索引') return False text = '' if left is None and upper is not None: @@ -174,7 +177,7 @@ def set(self, main, left=None, right=None, upper=None, bottom=None, skip_first_s for k in ['left', 'right', 'upper', 'bottom']: if locals().get(k, None) is not None: text += f'{k}={locals().get(k, None)} ' - logger.info(f'{self.name} set to {text.strip()}') + logger.info(f'[UI-导航栏] {self.name} 设置为 {text.strip()}') interval = Timer(2, count=4) timeout = Timer(10, count=20).start() @@ -185,7 +188,7 @@ def set(self, main, left=None, right=None, upper=None, bottom=None, skip_first_s main.device.screenshot() if timeout.reached(): - logger.warning(f'{self.name} failed to set {text.strip()}') + logger.warning(f'[UI-导航栏] {self.name} 设置 {text.strip()} 超时') return False if self._shop_obstruct_handle(main=main): @@ -194,7 +197,7 @@ def set(self, main, left=None, right=None, upper=None, bottom=None, skip_first_s continue active, minimum, maximum = self.get_info(main=main) - logger.info(f'Nav item active: {active} from range ({minimum}, {maximum})') + logger.info(f'[UI-导航栏] 激活项: {active},范围 ({minimum}, {maximum})') # 收到纯黑截图时会返回 None # Active 为 None 可能是因为动画尚未加载完成 if active is None or minimum is None or maximum is None: @@ -203,7 +206,7 @@ def set(self, main, left=None, right=None, upper=None, bottom=None, skip_first_s index = minimum + left - 1 if left is not None else maximum - right + 1 if not minimum <= index <= maximum: logger.warning( - f'Index to set ({index}) is not within the nav items that appears ({minimum}, {maximum})') + f'[UI-导航栏] 设置索引 ({index}) 不在导航项范围内 ({minimum}, {maximum})') continue # End diff --git a/module/ui/page.py b/module/ui/page.py index 97192ad28..26f576b09 100644 --- a/module/ui/page.py +++ b/module/ui/page.py @@ -1,3 +1,23 @@ +"""UI 页面定义和导航图。 + +定义碧蓝航线游戏中的所有 UI 页面及其导航关系。 +每个页面由一个检查按钮(check_button)标识,页面之间通过按钮点击建立链接。 + +导航系统使用 A* 寻路算法找到从当前页面到目标页面的最短路径。 +导航图在运行时通过 `init_connection()` 动态构建。 + +页面层次: +- 主页面(page_main):游戏主界面,所有功能的入口 +- 功能页面:战役、活动、商店、宿舍等 +- 子页面:关卡选择、舰队准备等 + +使用示例: + >>> page_main.link(button=MAIN_GOTO_CAMPAIGN, destination=page_campaign) + >>> # 从任意页面导航到 page_campaign + >>> Page.init_connection(page_main) + >>> # 之后可通过 page.parent 沿路径回溯 +""" + import traceback from module.coalition.assets import * @@ -11,6 +31,19 @@ class Page: + """UI 页面定义类。 + + 每个 Page 实例代表游戏中的一个可导航页面。 + 通过 `link()` 方法建立页面间的导航关系,形成有向图。 + `init_connection()` 使用 BFS 算法预计算从目标页面到所有源页面的最短路径。 + + Attributes: + all_pages (dict[str, Page]): 全局页面注册表,键为页面变量名。 + check_button (Button): 页面标识按钮,用于检测当前是否在此页面。 + links (dict[Page, Button]): 到达其他页面的链接,键为目标页面,值为点击按钮。 + name (str): 页面变量名,如 'page_main'、'page_campaign'。 + parent (Page | None): 在 A* 寻路中指向目标方向的父页面。 + """ # 键: str, 页面名称如 "page_main" # 值: Page, 页面实例 all_pages = {} diff --git a/module/ui/scroll.py b/module/ui/scroll.py index 95da81eda..4f874dc2e 100644 --- a/module/ui/scroll.py +++ b/module/ui/scroll.py @@ -1,3 +1,6 @@ +"""游戏滚动条控制模块。定义 Scroll 类,通过颜色识别滚动条位置, +支持拖拽滚动、翻页和基于百分比的精确定位。""" + import numpy as np from scipy import signal diff --git a/module/ui/setting.py b/module/ui/setting.py index 3e9dde0e6..e77a64647 100644 --- a/module/ui/setting.py +++ b/module/ui/setting.py @@ -1,3 +1,6 @@ +"""游戏设置面板模块。定义 Setting 类,封装游戏中设置面板的选项切换逻辑, +支持多组设置项的管理和状态追踪。""" + import copy import typing as t @@ -135,7 +138,7 @@ def _set_execute(self, **kwargs): """ status = self._product_setting_status(**kwargs) - logger.info(f'Setting options {self.name}, {dict_to_kv(kwargs)}') + logger.info(f'[UI-设置] 设置选项 {self.name}, {dict_to_kv(kwargs)}') skip_first_screenshot = True retry = Timer(1, count=2) timeout = Timer(10, count=20).start() diff --git a/module/ui/switch.py b/module/ui/switch.py index dac78e663..fe327e1b1 100644 --- a/module/ui/switch.py +++ b/module/ui/switch.py @@ -1,3 +1,6 @@ +"""游戏开关控件模块。定义 Switch 类,封装游戏中开关/选择器的状态切换逻辑, +支持带重试机制的多状态切换。""" + from module.base.base import ModuleBase from module.base.timer import Timer from module.exception import ScriptError @@ -176,8 +179,8 @@ def set(self, state, main, skip_first_screenshot=True): # 未知状态警告 if current == 'unknown': if unknown_timer.reached(): - logger.warning(f'Switch {self.name} has states evaluated to unknown, ' - f'asset should be re-verified') + logger.warning(f'[UI-开关] 开关 {self.name} 状态评估为未知,' + f'资源应重新验证') has_unknown = True unknown_timer.reset() # 如果 unknown_timer 从未触发,不点击未知状态(可能是切换动画)。 diff --git a/module/ui/ui.py b/module/ui/ui.py index 35831cc73..12be5b715 100644 --- a/module/ui/ui.py +++ b/module/ui/ui.py @@ -1,3 +1,28 @@ +"""UI 导航核心模块。 + +提供游戏页面间的自动导航功能,是所有需要页面切换的操作的基础。 + +核心方法: +- ui_goto(page): 沿最短路径导航到目标页面 +- ui_ensure(page): 检测当前页面并导航到目标页面 +- ui_page_appear(page): 检测指定页面是否出现 +- ui_back(): 点击返回按钮 +- ui_get_current_page(): 检测当前所在页面 + +导航机制: +1. 通过 Page.init_connection() 预计算页面间的最短路径 +2. 每个页面有 check_button 用于检测 +3. 页面间通过 link(button, destination) 建立连接 +4. 导航时沿 parent 链逐页跳转 + +特殊处理: +- 弹窗关闭:导航过程中自动关闭各种弹窗 +- 剧情跳过:自动跳过插入的剧情 +- 页面等待:等待页面完全加载后再继续 + +继承自 InfoHandler,可处理导航过程中的各种弹窗。 +""" + from module.base.button import Button from module.base.decorator import run_once from module.base.timer import Timer @@ -22,6 +47,14 @@ class UI(InfoHandler): + """UI 导航核心类。 + + 提供游戏页面间的自动导航功能。所有需要页面切换的操作 + 都通过此类的方法进行导航。 + + Attributes: + ui_current (Page): 当前所在的页面。 + """ ui_current: Page def ui_page_appear(self, page, offset=(30, 30), interval=0): @@ -99,7 +132,7 @@ def ui_click( retry_wait (int, float): 重试等待时间(秒)。 skip_first_screenshot (bool): 是否跳过首次截图。 """ - logger.hr("UI click") + logger.hr("UI 点击") if appear_button is None: appear_button = click_button @@ -163,12 +196,12 @@ def ui_get_current_page(self, skip_first_screenshot=True): Returns: Page: 当前页面对象。 """ - logger.info("UI get current page") + logger.info("UI 获取当前页面") @run_once def app_check(): if not self.device.app_is_running(): - raise GameNotRunningError("Game not running") + raise GameNotRunningError("[UI] 游戏未运行") @run_once def minicap_check(): @@ -200,7 +233,7 @@ def minicap_check(): return page # 未知页面但可以处理 - logger.info("Unknown ui page") + logger.info("[UI] 未知UI页面") if self.appear_then_click(GOTO_MAIN, offset=(30, 30), interval=2): timeout.reset() continue @@ -222,20 +255,20 @@ def minicap_check(): orientation_timer.reset() # 未知页面,需要手动切换 - logger.warning("Unknown ui page") - logger.attr("EMULATOR__SCREENSHOT_METHOD", self.config.Emulator_ScreenshotMethod) - logger.attr("EMULATOR__CONTROL_METHOD", self.config.Emulator_ControlMethod) - logger.attr("SERVER", self.config.SERVER) - logger.warning("Starting from current page is not supported") - logger.warning(f"Supported page: {[str(page) for page in Page.iter_pages()]}") - logger.warning('Supported page: Any page with a "HOME" button on the upper-right') + logger.warning("[UI] 未知UI页面") + logger.attr("模拟器截图方式", self.config.Emulator_ScreenshotMethod) + logger.attr("模拟器控制方式", self.config.Emulator_ControlMethod) + logger.attr("服务器", self.config.SERVER) + logger.warning("[UI] 不支持从当前页面启动") + logger.warning(f"[UI] 支持的页面: {[str(page) for page in Page.iter_pages()]}") + logger.warning('[UI] 支持的页面: 任何右上角有"HOME"按钮的页面') logger.critical("[UI] 杂鱼大叔~ 这么大个人了连主界面都进不去吗?噗噗,简直像个迷路的小宝宝❤") logger.critical("[UI] 听好了,笨蛋大叔:要么滚去正常的界面启动," "要么找个带『一键回港』按钮的界面再求我。你要是连这都找不到,建议直接把号删了止损。") logger.critical("[UI] 看懂了吗?废材?不要再浪费我的算力了,赶紧去改!") # 未知页面自动重启 - logger.warning("Unknown page detected, try to restart game") + logger.warning("[UI] 检测到未知页面,尝试重启游戏") from module.handler.login import LoginHandler login_handler = LoginHandler(config=self.config, device=self.device) login_handler.device.app_stop() @@ -259,7 +292,7 @@ def ui_goto(self, destination, get_ship=True, offset=(30, 30), skip_first_screen Page.init_connection(destination) self.interval_clear(list(Page.iter_check_buttons())) - logger.hr(f"UI goto {destination}") + logger.hr(f"UI 导航到 {destination}") while 1: GOTO_MAIN.clear_offset() if skip_first_screenshot: @@ -269,12 +302,12 @@ def ui_goto(self, destination, get_ship=True, offset=(30, 30), skip_first_screen # 到达目标页面 if self.ui_page_appear(page=destination, offset=offset): - logger.info(f'Page arrive: {destination}') + logger.info(f'[UI] 到达页面: {destination}') break # 主界面新旧主题互为等价:目标为任一主界面时, # 检测到另一主题也视为到达 if destination in (page_main, page_main_white) and self.is_in_main(): - logger.info(f'Page arrive: {destination}') + logger.info(f'[UI] 到达页面: {destination}') break # 其他页面:按 A* 路径点击导航 @@ -283,7 +316,7 @@ def ui_goto(self, destination, get_ship=True, offset=(30, 30), skip_first_screen if page.parent is None or page.check_button is None: continue if self.appear(page.check_button, offset=offset, interval=5): - logger.info(f'Page switch: {page} -> {page.parent}') + logger.info(f'[UI] 页面切换: {page} -> {page.parent}') button = page.links[page.parent] self.device.click(button) self.ui_button_interval_reset(button) @@ -310,17 +343,17 @@ def ui_ensure(self, destination, skip_first_screenshot=True): Returns: bool: 是否发生了页面切换。 """ - logger.hr("UI ensure") + logger.hr("UI 确保页面") self.ui_get_current_page(skip_first_screenshot=skip_first_screenshot) if self.ui_current == destination: - logger.info("Already at %s" % destination) + logger.info("[UI] 已在 %s" % destination) return False # 主界面新旧主题互为等价 if {self.ui_current, destination} == {page_main, page_main_white}: - logger.info("Already at %s (equivalent main page)" % destination) + logger.info("[UI] 已在 %s (等效主界面)" % destination) return False else: - logger.info("Goto %s" % destination) + logger.info("[UI] 导航到 %s" % destination) self.ui_goto(destination, skip_first_screenshot=True) return True @@ -360,7 +393,7 @@ def ui_ensure_index( interval (tuple, int, float): 两次点击之间的间隔(秒)。 stall_tolerance (int): 允许索引连续不变的次数,超过后判定为无法达到目标并退出。 """ - logger.hr("UI ensure index") + logger.hr("UI 确保索引") retry = Timer(1, count=2) # 进度停滞检测:记录上一次索引值和连续不变次数,防止因游戏端限制导致死循环 last_current = None @@ -376,7 +409,7 @@ def ui_ensure_index( else: current = letter(self.device.image) - logger.attr("Index", current) + logger.attr("当前索引", current) diff = index - current if diff == 0: break @@ -388,8 +421,8 @@ def ui_ensure_index( stall_count = 0 last_current = current if stall_count >= stall_tolerance: - logger.warning(f'Index stuck at {current} for {stall_count} times, ' - f'unable to reach target {index}, skipping') + logger.warning(f'[UI] 索引停滞在 {current} 已 {stall_count} 次,' + f'无法达到目标 {index},跳过') break if retry.reached(): @@ -438,7 +471,7 @@ def ui_page_main_popups(self, get_ship=True): if self.appear_then_click(LOGIN_RETURN_SIGN, offset=(30, 30), interval=3): return True if self.appear(EVENT_LIST_CHECK, offset=(30, 30), interval=5): - logger.info(f'UI additional: {EVENT_LIST_CHECK} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {EVENT_LIST_CHECK} -> {GOTO_MAIN}') if self.appear_then_click(GOTO_MAIN, offset=(30, 30)): return True # 月卡即将到期 @@ -453,7 +486,7 @@ def ui_page_main_popups(self, get_ship=True): # return True # 通行券新赛季通知弹窗 if self.appear(BATTLE_PASS_NEW_SEASON, offset=(30, 30), interval=3): - logger.info(f'UI additional: {BATTLE_PASS_NEW_SEASON} -> {BACK_ARROW}') + logger.info(f'[UI-额外] {BATTLE_PASS_NEW_SEASON} -> {BACK_ARROW}') self.device.click(BACK_ARROW) return True # 物品过期 offset=(37, 72),皮肤过期 offset=(24, 68) @@ -464,16 +497,16 @@ def ui_page_main_popups(self, get_ship=True): return True # 从确认点击误入的页面 if self.appear(SHIPYARD_CHECK, offset=(30, 30), interval=5): - logger.info(f'UI additional: {SHIPYARD_CHECK} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {SHIPYARD_CHECK} -> {GOTO_MAIN}') if self.appear_then_click(GOTO_MAIN, offset=(30, 30)): return True if self.appear(META_CHECK, offset=(30, 30), interval=5): - logger.info(f'UI additional: {META_CHECK} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {META_CHECK} -> {GOTO_MAIN}') if self.appear_then_click(GOTO_MAIN, offset=(30, 30)): return True # 误点击 if self.appear(PLAYER_CHECK, offset=(30, 30), interval=3): - logger.info(f'UI additional: {PLAYER_CHECK} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {PLAYER_CHECK} -> {GOTO_MAIN}') if self.appear_then_click(GOTO_MAIN, offset=(30, 30)): return True if self.appear_then_click(BACK_ARROW, offset=(30, 30)): @@ -503,7 +536,7 @@ def ui_page_os_popups(self): self.interval_reset(RESET_TICKET_POPUP) return True if self.appear(EXCHANGE_CHECK, offset=(30, 30), interval=3): - logger.info(f'UI additional: {EXCHANGE_CHECK} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {EXCHANGE_CHECK} -> {GOTO_MAIN}') GOTO_MAIN.clear_offset() self.device.click(GOTO_MAIN) return True @@ -543,7 +576,7 @@ def ui_additional(self, get_ship=True): # 度假村的活动委托提示 # 2025.05.29 进入船坞时出现的皮肤功能提示 if self.appear(GAME_TIPS, offset=(30, 30), interval=2): - logger.info(f'UI additional: {GAME_TIPS} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {GAME_TIPS} -> {GOTO_MAIN}') self.device.click(GOTO_MAIN) return True @@ -561,7 +594,7 @@ def ui_additional(self, get_ship=True): self.interval_reset(GET_SHIP) return True if self.appear(MEOWFFICER_BUY, offset=(30, 30), interval=3): - logger.info(f'UI additional: {MEOWFFICER_BUY} -> {BACK_ARROW}') + logger.info(f'[UI-额外] {MEOWFFICER_BUY} -> {BACK_ARROW}') self.device.click(BACK_ARROW) self.interval_reset(GET_SHIP) return True @@ -585,14 +618,14 @@ def ui_additional(self, get_ship=True): # - Alas 切换到 page_campaign 并从已有关卡撤退 # - 游戏客户端在 page_campaign W12 界面卡死,点击屏幕无响应 # - 再次重启游戏客户端可修复此问题 - logger.info("WITHDRAW button found, wait until map loaded to prevent bugs in game client") + logger.info("[UI-额外] 发现撤退按钮,等待地图加载以防止游戏客户端bug") self.device.sleep(2) self.device.screenshot() if self.appear_then_click(WITHDRAW, offset=(30, 30)): self.interval_reset(WITHDRAW) return True else: - logger.warning("WITHDRAW button does not exist anymore") + logger.warning("[UI-额外] 撤退按钮已不存在") self.interval_reset(WITHDRAW) # 登录相关 @@ -603,7 +636,7 @@ def ui_additional(self, get_ship=True): # 误点击 if self.appear(EXERCISE_PREPARATION, interval=3): - logger.info(f'UI additional: {EXERCISE_PREPARATION} -> {GOTO_MAIN}') + logger.info(f'[UI-额外] {EXERCISE_PREPARATION} -> {GOTO_MAIN}') self.device.click(GOTO_MAIN) return True @@ -630,7 +663,7 @@ def ui_additional(self, get_ship=True): return True # 白色主题 UI 切换,无偏移量仅颜色匹配 if self.appear(MAIN_GOTO_MEMORIES_WHITE, interval=3): - logger.info(f'UI additional: {MAIN_GOTO_MEMORIES_WHITE} -> {MAIN_TAB_SWITCH_WHITE}') + logger.info(f'[UI-额外] {MAIN_GOTO_MEMORIES_WHITE} -> {MAIN_TAB_SWITCH_WHITE}') self.device.click(MAIN_TAB_SWITCH_WHITE) return True @@ -647,17 +680,17 @@ def handle_idle_page(self): if not timer.reached(): return False if IDLE.match_luma(self.device.image, offset=(5, 5)): - logger.info(f'UI additional: {IDLE} -> {REWARD_GOTO_MAIN}') + logger.info(f'[UI-额外] {IDLE} -> {REWARD_GOTO_MAIN}') self.device.click(REWARD_GOTO_MAIN) timer.reset() return True if IDLE_2.match_luma(self.device.image, offset=(5, 5)): - logger.info(f'UI additional: {IDLE_2} -> {REWARD_GOTO_MAIN}') + logger.info(f'[UI-额外] {IDLE_2} -> {REWARD_GOTO_MAIN}') self.device.click(REWARD_GOTO_MAIN) timer.reset() return True if IDLE_3.match_luma(self.device.image, offset=(5, 5)): - logger.info(f'UI additional: {IDLE_3} -> {REWARD_GOTO_MAIN}') + logger.info(f'[UI-额外] {IDLE_3} -> {REWARD_GOTO_MAIN}') self.device.click(REWARD_GOTO_MAIN) timer.reset() return True diff --git a/module/ui_white/assets.py b/module/ui_white/assets.py index 49d1087f2..5e8803f3c 100644 --- a/module/ui_white/assets.py +++ b/module/ui_white/assets.py @@ -1,3 +1,5 @@ +"""UI白色主题模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/war_archives/assets.py b/module/war_archives/assets.py index 873db5b5c..65747ab67 100644 --- a/module/war_archives/assets.py +++ b/module/war_archives/assets.py @@ -1,3 +1,5 @@ +"""作战档案模块的 UI 资源定义。""" + from module.base.button import Button from module.base.template import Template diff --git a/module/war_archives/dictionary.py b/module/war_archives/dictionary.py index adece1bd0..774535557 100644 --- a/module/war_archives/dictionary.py +++ b/module/war_archives/dictionary.py @@ -1,3 +1,7 @@ +"""作战档案模板映射字典,将作战档案活动名称映射到对应的模板图像。 +用于在作战档案页面中通过模板匹配识别并选择 +特定的历史活动关卡。""" + from module.war_archives.assets import * dic_archives_template = { diff --git a/module/war_archives/war_archives.py b/module/war_archives/war_archives.py index 5dfb94447..3455a7694 100644 --- a/module/war_archives/war_archives.py +++ b/module/war_archives/war_archives.py @@ -1,3 +1,21 @@ +"""作战档案模块。 + +自动执行碧蓝航线的作战档案战役。作战档案是过往活动的复刻入口, +需要消耗数据密钥(Data Key)才能进入,每日上限 60 把。 + +本模块的核心功能: +- 通过 OCR 识别剩余数据密钥数量,控制出击节奏 +- 管理每日出击次数限制(可配置每日上限) +- 自动在数据密钥用尽或每日额度耗尽时停止任务 +- 通关后实时扣减每日额度并持久化到配置 + +注意:作战档案中禁用自动搜索续战功能,因为自动搜索菜单的模糊背景 +会遮挡数据密钥的 OCR 识别区域。 + +配置路径: WarArchives.DailyRunCount (每日出击上限), + StopCondition.OilLimit (燃油限制) +""" + import re from campaign.campaign_war_archives.campaign_base import CampaignBase @@ -37,6 +55,15 @@ def after_process(self, result): class CampaignWarArchives(CampaignRun, CampaignBase): + """作战档案战役执行器。 + + 继承自 CampaignRun(战役运行)和 CampaignBase(作战档案战役基础), + 在标准战役运行逻辑上增加作战档案特有的限制: + - 数据密钥消耗管理(强制启用 USE_DATA_KEY) + - 每日出击次数限制(跨天自动重置、配置变更实时调整) + - 数据密钥 OCR 检测(在档案战役界面识别剩余数量) + - 禁用自动搜索续战(避免遮挡 OCR 区域) + """ def daily_run_limit_reset(self): """刷新作战档案每日出击额度。 @@ -123,9 +150,9 @@ def triggered_stop_condition(self, oil_check=True): if self.appear(WAR_ARCHIVES_CAMPAIGN_CHECK, offset=(20, 20)): # 检查数据密钥是否已用尽 current, remain, total = DATA_KEY_CAMPAIGN.ocr(self.device.image) - logger.info(f'Inventory: {current} / {total}, Remain: {current}') + logger.info(f'[作战档案] 数据密钥: {current} / {total}, 剩余: {current}') if remain == total: - logger.hr('Triggered out of data keys') + logger.hr('[作战档案] 数据密钥已用尽') # 仅在数据密钥用尽时才能延迟任务 self.config.task_delay(server_update=True) return True diff --git a/module/webui/__init__.py b/module/webui/__init__.py index eaa317183..3c79500d9 100644 --- a/module/webui/__init__.py +++ b/module/webui/__init__.py @@ -1,3 +1,5 @@ +"""Web界面模块。""" + # 必须最先导入,初始化日志目录 from module.logger import logger import deploy.logger diff --git a/module/webui/api.py b/module/webui/api.py index 3c4af8518..5e3188cfd 100644 --- a/module/webui/api.py +++ b/module/webui/api.py @@ -1,3 +1,10 @@ +""" +Web界面 REST API 路由。 + +提供 Starlette 路由处理函数,包括大世界统计、AP 时间线、通知推送、 +截图获取、配置导入、远程设备控制等 HTTP/WebSocket 接口。 +""" + import asyncio import json import os @@ -46,7 +53,7 @@ def api_cl1_stats(request): stats = get_opsi_stats(instance_name=instance_name).get_detailed_summary() return JSONResponse({"success": True, "data": stats}) except Exception as e: - logger.error(f"api_cl1_stats error: {e}") + logger.error(f"api_cl1_stats错误: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) def api_ap_timeline(request): @@ -56,7 +63,7 @@ def api_ap_timeline(request): timeline = get_ap_timeline(instance_name=instance_name) return JSONResponse({"success": True, "data": timeline}) except Exception as e: - logger.error(f"api_ap_timeline error: {e}") + logger.error(f"api_ap_timeline错误: {e}") return JSONResponse({"success": False, "error": str(e)}, status_code=500) def serve_obs_overlay(request): @@ -828,12 +835,12 @@ def start(self): self.control_socket = self._connect_scrcpy_socket() device_name = self.video_socket.recv(64).decode("utf-8", errors="replace").rstrip("\x00") if device_name: - logger.attr("LiveScrcpyDevice", device_name) + logger.attr("Scrcpy直播设备", device_name) resolution = self.video_socket.recv(4) if len(resolution) != 4: raise RuntimeError("scrcpy 未返回视频分辨率") self.resolution = struct.unpack(">HH", resolution) - logger.attr("LiveScrcpyResolution", self.resolution) + logger.attr("Scrcpy直播分辨率", self.resolution) self.video_socket.settimeout(1) self.alive = True except Exception: @@ -1100,13 +1107,13 @@ async def _ws_live_ws_scrcpy(websocket, instance, fps, target_width, bitrate_sca session.resolution = (max(1, width), max(1, height)) session.device_name = info.get("device_name") or "" if session.device_name: - logger.attr("LiveWsScrcpyDevice", session.device_name) - logger.attr("LiveWsScrcpyResolution", session.resolution) + logger.attr("WsScrcpy直播设备", session.device_name) + logger.attr("WsScrcpy直播分辨率", session.resolution) await session.send_binary(_build_ws_scrcpy_video_settings(target_width, fps, session.bitrate)) preroll = await _collect_ws_scrcpy_preroll(session) codec_string = _h264_avc1_codec_from_chunks(preroll) - logger.attr("LiveWsScrcpyPreroll", f"{sum(len(item) for item in preroll)} bytes, {codec_string}") + logger.attr("WsScrcpy预缓冲", f"{sum(len(item) for item in preroll)} bytes, {codec_string}") await websocket.send_text(json.dumps({ "type": "ready", "mode": "ws-scrcpy", @@ -1165,7 +1172,7 @@ async def _ws_live_raw_scrcpy(websocket, instance, fps, target_width, bitrate_sc raise RuntimeError("scrcpy 未输出 H264 视频数据") codec_string = _h264_avc1_codec(preroll) description = _h264_avcc_description(preroll) - logger.attr("LiveScrcpyPreroll", f"{len(preroll)} bytes, {codec_string}") + logger.attr("Scrcpy预缓冲", f"{len(preroll)} bytes, {codec_string}") await websocket.send_text(json.dumps({ "type": "ready", "mode": "scrcpy", diff --git a/module/webui/app.py b/module/webui/app.py index f7aa68abb..146bb8124 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -1,4 +1,11 @@ -"""AzurPilot WebUI 的兼容入口和 ASGI 应用工厂。""" +"""AzurPilot WebUI 的兼容入口和 ASGI 应用工厂。 + +提供 WebUI 的主应用类,通过多个 Mixin 组合实现各功能页面: +仪表盘(Dashboard)、开发者菜单、开发者设置、开发者工具、 +版本更新、活动工具等。同时提供 ASGI 应用创建和路由注册。 + +该模块是 WebUI 的顶层入口,被 gui.py 启动时引用。 +""" from module.webui.app_dashboard import DashboardMixin from module.webui.app_dependencies import ( @@ -145,12 +152,12 @@ def app(): # 未传入 --run 时保持 None,由进程管理器跳过启动实例。 instances: List[str] | None = runs - logger.hr("Webui configs") - logger.attr("Theme", State.deploy_config.Theme) - logger.attr("Language", lang.LANG) - logger.attr("Password", is_webui_password_set(key)) + logger.hr("[WebUI] WebUI 配置") + logger.attr("主题", State.deploy_config.Theme) + logger.attr("语言", lang.LANG) + logger.attr("密码", is_webui_password_set(key)) logger.attr("CDN", cdn) - logger.attr("IS_ON_PHONE_CLOUD", IS_ON_PHONE_CLOUD) + logger.attr("云手机", IS_ON_PHONE_CLOUD) from deploy.atomic import atomic_failure_cleanup @@ -187,7 +194,7 @@ def _run_gui(initial_page: str = "home") -> None: if _block_restricted_device() or _block_public_webui_password_error(): return if is_webui_password_set(key) and not login(key): - logger.warning(f"{info.user_ip} login failed.") + logger.warning(f"[WebUI] {info.user_ip} 登录失败") time.sleep(1.5) run_js("location.reload();") return diff --git a/module/webui/app_event_tools.py b/module/webui/app_event_tools.py index 2540fe146..6e7425375 100644 --- a/module/webui/app_event_tools.py +++ b/module/webui/app_event_tools.py @@ -408,7 +408,7 @@ def _update_simulator_figure(): with use_scope("figure_display", clear=True): pass # Clear the image except Exception as e: - logger.warning(f"Failed to update simulator figure: {e}") + logger.warning(f"[WebUI-活动工具] 更新模拟器图表失败: {e}") else: with use_scope("figure_display", clear=True): pass # Clear the image diff --git a/module/webui/app_home.py b/module/webui/app_home.py index dafd6779e..7ccdf48b7 100644 --- a/module/webui/app_home.py +++ b/module/webui/app_home.py @@ -136,7 +136,7 @@ def _fetch_announcement_thread(self, force=False): data = ApiClient.get_announcement(timeout=10) self._announcement_result = (data, force) except Exception as e: - logger.error(f"Announcement fetch failed: {e}") + logger.error(f"[WebUI-主页] 获取公告失败: {e}") self._announcement_result = (None, force, str(e)) finally: self._announcement_fetching = False @@ -203,7 +203,7 @@ def _process_announcement_result(self): url_json = json.dumps(data.get("url", "")) force_json = "true" if force else "false" - logger.info(f"Pushing announcement: {data.get('title')}") + logger.info(f"[WebUI-主页] 推送公告: {data.get('title')}") run_js( f"window.alasShowAnnouncement({title_json}, {content_json}, {announcement_id_json}, {url_json}, {force_json});" ) diff --git a/module/webui/app_lifecycle.py b/module/webui/app_lifecycle.py index 93e409419..cc63987ee 100644 --- a/module/webui/app_lifecycle.py +++ b/module/webui/app_lifecycle.py @@ -60,7 +60,7 @@ def clearup() -> bool: if State._clearup: return True - logger.info("Start clearup") + logger.info("[WebUI-生命周期] 开始清理") success = _clearup_step("任务处理器", task_handler.stop) for name, handler in ( @@ -100,5 +100,5 @@ def clearup() -> bool: success = False else: logger.error("WebUI 清理未完成,保留 Manager 直到父进程终止进程树") - logger.info("Alas closed.") + logger.info("[WebUI-生命周期] Alas 已关闭") return success diff --git a/module/webui/app_stat_commission.py b/module/webui/app_stat_commission.py index b47203d66..71e57bbd7 100644 --- a/module/webui/app_stat_commission.py +++ b/module/webui/app_stat_commission.py @@ -36,7 +36,7 @@ def _render_commission_income(self): except Exception as e: with use_scope("commission_income", clear=True): put_text(t("Gui.Stat.CommissionIncomeNoData")) - logger.warning(f"Commission income render failed: {e}") + logger.warning(f"[WebUI-统计] 委托收入渲染失败: {e}") def _load_commission_income_data(self): from datetime import datetime diff --git a/module/webui/app_task_config.py b/module/webui/app_task_config.py index 661a3838c..7e7619d7c 100644 --- a/module/webui/app_task_config.py +++ b/module/webui/app_task_config.py @@ -288,7 +288,7 @@ def put_queue(path, value): pin_on_change( name="_".join(path), onchange=partial(put_queue, ".".join(path)) ) - logger.info("Init config watcher done.") + logger.info("[WebUI-任务配置] 配置监听器初始化完成") def _alas_thread_update_config(self) -> None: modified = {} @@ -406,7 +406,7 @@ def _save_config( else: modified.pop(k) invalid.append(k) - logger.warning(f"Invalid value {v} for key {k}, skip saving.") + logger.warning(f"[WebUI-任务配置] 无效值 {v},键 {k},跳过保存") self.pin_remove_invalid_mark(valid) self.pin_set_invalid_mark(invalid) if modified: @@ -417,7 +417,7 @@ def _save_config( color="success", ) logger.info( - f"Save config {filepath_config(config_name)}, {dict_to_kv(modified)}" + f"[WebUI-任务配置] 保存配置 {filepath_config(config_name)}, {dict_to_kv(modified)}" ) config_updater.write_file(config_name, config) except Exception as e: diff --git a/module/webui/base.py b/module/webui/base.py index a65a40492..db1dc41f9 100644 --- a/module/webui/base.py +++ b/module/webui/base.py @@ -1,3 +1,10 @@ +""" +Web界面基础框架。 + +提供 Base 和 Frame 两个核心类。Base 管理页面生命周期和后台任务调度, +Frame 实现侧边栏、菜单导航和内容区域的切换逻辑。 +""" + import json import threading diff --git a/module/webui/config.py b/module/webui/config.py index d9e9a09d6..62050cc44 100644 --- a/module/webui/config.py +++ b/module/webui/config.py @@ -1,3 +1,10 @@ +""" +Web界面部署配置管理。 + +提供 DeployConfig 的 WebUI 子类,将配置变更实时写入部署文件。 +通过 __setattr__ 拦截属性修改,自动同步到磁盘配置。 +""" + from deploy.config import DeployConfig as _DeployConfig diff --git a/module/webui/dashboard_utils.py b/module/webui/dashboard_utils.py index 0d713ad02..1c63e0382 100644 --- a/module/webui/dashboard_utils.py +++ b/module/webui/dashboard_utils.py @@ -1,3 +1,10 @@ +""" +仪表盘 Scope ID 管理工具。 + +集中管理仪表盘相关的 PyWebIO scope 命名,避免重复创建和命名冲突。 +对 group_name 进行清洗,确保生成的 scope_id 是有效的标识符。 +""" + # Dashboard scope ID 管理工具 # 用于集中管理 dashboard 相关的 scope 命名,避免重复创建和命名冲突 diff --git a/module/webui/deploy_settings.py b/module/webui/deploy_settings.py index 52d344e61..2d832c4fc 100644 --- a/module/webui/deploy_settings.py +++ b/module/webui/deploy_settings.py @@ -1,3 +1,10 @@ +""" +Web界面部署设置定义。 + +定义部署设置的数据结构、主题选项、远程访问模式等配置字段。 +提供设置的序列化、反序列化及启动运行项管理功能。 +""" + import json from dataclasses import dataclass from typing import Any diff --git a/module/webui/discord_presence.py b/module/webui/discord_presence.py index e88cee195..84fb83bdd 100644 --- a/module/webui/discord_presence.py +++ b/module/webui/discord_presence.py @@ -1,3 +1,10 @@ +""" +Discord Rich Presence 集成。 + +通过 pypresence 库连接 Discord RPC,展示 AzurPilot 的运行状态。 +提供初始化和关闭接口,异步更新 Discord 状态信息。 +""" + import asyncio import time diff --git a/module/webui/event_calculator.py b/module/webui/event_calculator.py index 4b6422bf3..886ade06d 100644 --- a/module/webui/event_calculator.py +++ b/module/webui/event_calculator.py @@ -1,3 +1,7 @@ +"""WebUI 活动计算器,从碧蓝航线 Wiki 同步活动数据并在本地渲染计算器。 +支持活动商店物品的价格解析、资源需求计算, +以及计算结果的 HTML 界面展示。""" + # 从碧蓝航线 Wiki 活动计算器同步数据,并在 WebUI 中渲染本地计算器。 import json import os @@ -200,7 +204,7 @@ def _read_cache() -> Dict[str, Any]: except FileNotFoundError: return {} except Exception as e: - logger.warning(f"Read Wiki event calculator cache failed: {e}") + logger.warning(f"[WebUI-计算器] 读取Wiki活动计算器缓存失败: {e}") return {} @@ -211,7 +215,7 @@ def _write_cache(data: Dict[str, Any]) -> None: with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: - logger.warning(f"Write Wiki event calculator cache failed: {e}") + logger.warning(f"[WebUI-计算器] 写入Wiki活动计算器缓存失败: {e}") def load_event_calculator(force_refresh: bool = False) -> Dict[str, Any]: @@ -230,7 +234,7 @@ def load_event_calculator(force_refresh: bool = False) -> Dict[str, Any]: _write_cache(data) return {**data, "from_cache": False} except Exception as e: - logger.warning(f"Fetch Wiki event calculator failed: {e}") + logger.warning(f"[WebUI-计算器] 获取Wiki活动计算器失败: {e}") if cache: return {**cache, "from_cache": True, "error": str(e)} return {"error": str(e), "from_cache": False} diff --git a/module/webui/fake_pil_module.py b/module/webui/fake_pil_module.py index ac31239a0..bb385889c 100644 --- a/module/webui/fake_pil_module.py +++ b/module/webui/fake_pil_module.py @@ -1,3 +1,10 @@ +""" +伪造 PIL 模块。 + +在子进程启动时注入虚拟的 PIL 模块到 sys.modules,避免加载真实的 +图像处理库。用于减少进程管理器等非图像处理场景的启动开销。 +""" + import sys from types import ModuleType diff --git a/module/webui/lang.py b/module/webui/lang.py index a47331333..8480ea4a4 100644 --- a/module/webui/lang.py +++ b/module/webui/lang.py @@ -1,3 +1,10 @@ +""" +Web界面多语言支持。 + +管理 WebUI 的语言切换和翻译文本加载。从 i18n JSON 文件读取翻译, +通过 t() 函数获取当前语言的翻译文本,支持 zh-CN/en-US/ja-JP/zh-TW。 +""" + from typing import Dict from module.config.deep import deep_iter diff --git a/module/webui/launcher.py b/module/webui/launcher.py index 42b31ffb0..7a4b5ddcf 100644 --- a/module/webui/launcher.py +++ b/module/webui/launcher.py @@ -1,3 +1,7 @@ +"""WebUI 启动器工具模块,提供实例管理的进程间通信和本地请求检测。 +包括异步命令执行、WebSocket 连接管理、 +本地请求判断等底层支持功能。""" + import asyncio import json import sys diff --git a/module/webui/oobe.py b/module/webui/oobe.py index 283747750..8438f8f3e 100644 --- a/module/webui/oobe.py +++ b/module/webui/oobe.py @@ -1,3 +1,10 @@ +""" +首次启动向导(OOBE)。 + +在用户首次启动且没有配置文件时,通过 PyWebIO 引导完成基本设置, +包括语言选择、实例命名等初始化配置。 +""" + # OOBE (Out-Of-Box Experience) 初次设置向导 # 在用户首次启动、没有配置文件时引导完成基本设置 # diff --git a/module/webui/patch.py b/module/webui/patch.py index df4975015..f8a5065b8 100644 --- a/module/webui/patch.py +++ b/module/webui/patch.py @@ -20,7 +20,7 @@ class CachedThreadPoolExecutor: def executor(cls): from concurrent.futures.thread import ThreadPoolExecutor pool = ThreadPoolExecutor(max_workers=5) - logger.info('Patched ThreadPoolExecutor created') + logger.info('[WebUI-补丁] 已创建补丁线程池') return pool diff --git a/module/webui/process_manager.py b/module/webui/process_manager.py index b1326b324..ab3546ed9 100644 --- a/module/webui/process_manager.py +++ b/module/webui/process_manager.py @@ -1,3 +1,10 @@ +""" +实例进程管理器。 + +管理 Alas 多实例运行时的进程生命周期,包括进程池维护、状态追踪 +(运行中/停止/异常)及进程间通信的安全处理逻辑。 +""" + import argparse # 此文件专门用于管理 Alas 运行时各实例进程的生存周期及其子进程。 @@ -216,19 +223,19 @@ def stop(self) -> bool: stopped = self._unregister_process() if stopped and pid is not None: self.renderables.append( - Text(f"[{self.config_name}] exited. Reason: Manual stop\n") + Text(f"[{self.config_name}] 已退出。原因:手动停止\n") ) if not stopped: - logger.error(f"[{self.config_name}] failed to stop worker PID {pid}") + logger.error(f"[{self.config_name}] 停止工作进程失败 PID {pid}") log_queue_handler = self.thd_log_queue_handler if log_queue_handler is not None: log_queue_handler.join(timeout=1) if log_queue_handler.is_alive(): logger.warning( - "Log queue handler thread does not stop within 1 seconds" + "[WebUI-进程管理] 日志队列处理线程未在 1 秒内停止" ) if stopped: - logger.info(f"[{self.config_name}] exited") + logger.info(f"[{self.config_name}] 已退出") else: logger.warning(f"[{self.config_name}] worker 未完全停止") return stopped @@ -335,10 +342,10 @@ def _kill_process_tree(pid: int) -> bool: return ProcessManager._wait_pid_exit(pid, timeout=3) if not ProcessManager._pid_exists(pid): return True - logger.warning(f"Failed to stop worker PID {pid}: taskkill returned {result.returncode}") + logger.warning(f"[WebUI-进程管理] 停止工作进程失败 PID {pid}: taskkill 返回 {result.returncode}") return False except (OSError, subprocess.TimeoutExpired) as exc: - logger.warning(f"Failed to stop worker PID {pid}: {exc}") + logger.warning(f"[WebUI-进程管理] 停止工作进程失败 PID {pid}: {exc}") return False else: try: @@ -503,7 +510,7 @@ def _thread_log_queue_handler(self) -> None: self.renderables.append(log) if len(self.renderables) > self.renderables_max_length: self.renderables = self.renderables[self.renderables_reduce_length :] - logger.info("End of log queue handler loop") + logger.info("日志队列处理循环结束") @property def alive(self) -> bool: @@ -636,11 +643,11 @@ def run_process( set_func_logger(func=q.put) if os.environ.get("DEMO") == "1": - logger.info("Log3") + logger.info("[WebUI-进程] 日志3") time.sleep(1) - logger.info("Log2") + logger.info("[WebUI-进程] 日志2") time.sleep(1) - logger.info("Log1") + logger.info("[WebUI-进程] 日志1") time.sleep(1) logger.info("[WebUI] 此版本为演示用途") return @@ -688,9 +695,9 @@ def run_process( f"[WebUI] 杂鱼大叔,连功能模块都找不到吗?{func} 这种东西根本不存在啦~" ) if e is not None and e.is_set(): - logger.info(f"[{config_name}] exited. Reason: Update\n") + logger.info(f"[{config_name}] 已退出。原因:更新\n") else: - logger.info(f"[{config_name}] exited. Reason: Finish\n") + logger.info(f"[{config_name}] 已退出。原因:完成\n") except Exception as ex: logger.exception(ex) @@ -714,7 +721,7 @@ def restart_processes( instances: 需要重启的实例列表,元素为 ProcessManager 或配置名称字符串。 ev: 用于通知子进程执行更新的事件对象。 """ - logger.hr("Restart alas") + logger.hr("[WebUI-进程管理] 重启 Alas") # 加载 MOD_CONFIG_DICT list_mod_instance() @@ -739,11 +746,11 @@ def restart_processes( pass for process in _instances: - logger.info(f"Starting [{process.config_name}]") + logger.info(f"启动中 [{process.config_name}]") process.start(func=get_config_mod(process.config_name), ev=ev) try: os.remove("./config/reloadalas") except: pass - logger.info("Start alas complete") + logger.info("[WebUI-进程管理] 启动 Alas 完成") diff --git a/module/webui/remote_access.py b/module/webui/remote_access.py index 5ba0c65bb..625282c4d 100644 --- a/module/webui/remote_access.py +++ b/module/webui/remote_access.py @@ -233,7 +233,7 @@ def _max_redirects(self) -> int: try: return max(0, int(getattr(State.deploy_config, "MaxRedirects", 2) or 0)) except (TypeError, ValueError): - logger.warning("Invalid MaxRedirects, fallback to 2") + logger.warning("无效的MaxRedirects,回退到2") return 2 def _redirect_hosts(self, primary_host: str) -> List[str]: @@ -283,10 +283,10 @@ def _start_ssh_process( f"-p {server_port} {server} -- --output json" ) args = shlex.split(cmd) - logger.debug(f"remote access service command: {cmd}") + logger.debug(f"[WebUI-远程访问] 远程访问服务命令: {cmd}") if self.process is not None and self.process.poll() is None: - logger.warning(f"Kill previous ssh process [{self.process.pid}]") + logger.warning(f"终止之前的SSH进程 [{self.process.pid}]") self.process.kill() try: self.process = Popen(args, stdout=PIPE, stderr=PIPE) @@ -298,7 +298,7 @@ def _start_ssh_process( self.info.error = "ssh_not_found" return None - logger.info(f"remote access process pid: {self.process.pid}") + logger.info(f"远程访问进程PID: {self.process.pid}") return self.process def _run( @@ -330,7 +330,7 @@ def _run( def timeout_killer(wait_sec, target_process): time.sleep(wait_sec) if not success and target_process.poll() is None: - logger.info("Connection timeout, kill ssh process") + logger.info("连接超时,终止SSH进程") target_process.kill() threading.Thread( @@ -340,7 +340,7 @@ def timeout_killer(wait_sec, target_process): ).start() stdout = process.stdout.readline().decode("utf8") - logger.debug(f"ssh server stdout: {stdout}") + logger.debug(f"[WebUI-远程访问] SSH 服务器标准输出: {stdout}") try: connection_info = json.loads(stdout) except json.JSONDecodeError: @@ -352,7 +352,7 @@ def timeout_killer(wait_sec, target_process): self.info.error = "ssh_host_key_changed" elif stderr: self.info.error = stderr.strip() - logger.error(f"SSH remote access exited before registration: {stderr.strip()}") + logger.error(f"SSH远程访问在注册前退出: {stderr.strip()}") else: self.info.error = "invalid_provider_response" break @@ -362,7 +362,7 @@ def timeout_killer(wait_sec, target_process): redirects += 1 if redirects > self._max_redirects(): self.info.error = "too_many_redirects" - logger.error("Too many SSH redirect responses") + logger.error("SSH重定向响应过多") self._terminate_process() break ssh_server = connection_info.get("ssh_server") @@ -376,7 +376,7 @@ def timeout_killer(wait_sec, target_process): redirect_user = connection_info.get("ssh_user") or primary_user or State.deploy_config.SSHUser current_server = f"{redirect_user}@{redirect_host}" if redirect_user else redirect_host current_port = redirect_port - logger.info(f"Remote access redirected to {redirect_host}:{redirect_port}") + logger.info(f"远程访问重定向到 {redirect_host}:{redirect_port}") self._terminate_process() continue @@ -390,7 +390,7 @@ def timeout_killer(wait_sec, target_process): ) new_username = connection_info.get("change_username", None) if new_username: - logger.info(f"Server requested to change username, change it to: {new_username}") + logger.info(f"服务器请求更改用户名,更改为: {new_username}") State.deploy_config.SSHUser = new_username break @@ -401,7 +401,7 @@ def timeout_killer(wait_sec, target_process): self.info.ice_servers = connection_info.get("ice_servers") self.info.connection_state = "ssh_forward" self.info.error = "" - logger.debug(f"Remote access url: {self.info.address}") + logger.debug(f"[WebUI-远程访问] 远程访问 URL: {self.info.address}") break while ( @@ -414,17 +414,17 @@ def timeout_killer(wait_sec, target_process): if self.process and self.process.poll() is None: if self.stop_event.is_set(): - logger.info("Stop SSH remote access service") + logger.info("停止SSH远程访问服务") else: - logger.info("App process exit, killing ssh process") + logger.info("应用进程退出,终止SSH进程") self.process.kill() elif self.process: stderr = self.process.stderr.read().decode("utf8") if stderr: - logger.error(f"PyWebIO application remote access service error: {stderr}") + logger.error(f"PyWebIO应用远程访问服务错误: {stderr}") self.info.error = stderr.strip() else: - logger.info("PyWebIO application remote access service exit.") + logger.info("PyWebIO应用远程访问服务退出.") self.info.connection_state = "stopped" self.info.address = None @@ -436,7 +436,7 @@ def start(self) -> None: self.notfound = False server, server_port = _parse_host_port(State.deploy_config.SSHServer) if State.deploy_config.SSHUser is None: - logger.info("SSHUser is not set, generate a random one") + logger.info("SSHUser未设置,生成随机用户") State.deploy_config.SSHUser = random_id(24) target = f"{State.deploy_config.SSHUser}@{server}" @@ -453,7 +453,7 @@ def start(self) -> None: self.thread.start() def _thread_main(self, **kwargs) -> None: - logger.info("Start SSH remote access service") + logger.info("启动SSH远程访问服务") reconnect_delay = SSH_RECONNECT_DELAY while not self.stop_event.is_set(): try: @@ -462,21 +462,21 @@ def _thread_main(self, **kwargs) -> None: break except Exception as e: self.info.error = str(e) - logger.warning(f"SSH remote access service error: {e}") + logger.warning(f"SSH远程访问服务错误: {e}") if self.stop_event.is_set() or self.notfound: break - logger.warning(f"SSH remote access disconnected, retry in {reconnect_delay} seconds") + logger.warning(f"[WebUI-远程] SSH远程访问断开,重试间隔 {reconnect_delay} 秒") self.info.connection_state = "reconnecting" if self.stop_event.wait(reconnect_delay): break reconnect_delay = min(reconnect_delay * 2, SSH_RECONNECT_MAX_DELAY) if self.process and self.process.poll() is None: - logger.info("Stop SSH remote access process") + logger.info("停止SSH远程访问进程") self.process.kill() - logger.info("Exit SSH remote access service thread") + logger.info("退出SSH远程访问服务线程") def stop(self) -> None: self.stop_event.set() @@ -620,7 +620,7 @@ async def _http_request(self, payload: dict) -> None: }) self.send_json({"type": "http.response.end", "id": req_id}) except Exception as e: - logger.warning(f"P2P HTTP proxy failed: {e}") + logger.warning(f"P2P HTTP代理失败: {e}") self.send_json({"type": "http.response.error", "id": req_id, "message": str(e)}) async def _ws_open(self, payload: dict) -> None: @@ -643,7 +643,7 @@ async def _ws_open(self, payload: dict) -> None: except Exception as e: if session is not None: await session.close() - logger.warning(f"P2P WebSocket open failed: {e}") + logger.warning(f"P2P WebSocket打开失败: {e}") self.send_json({"type": "ws.error", "id": ws_id, "message": str(e)}) async def _ws_reader(self, ws_id, session, ws) -> None: @@ -741,7 +741,7 @@ async def _sse_reader(self, payload: dict) -> None: except asyncio.CancelledError: raise except Exception as e: - logger.warning(f"P2P SSE proxy failed: {e}") + logger.warning(f"P2P SSE代理失败: {e}") finally: self.sse_tasks.pop(sse_id, None) self.send_json({"type": "sse.closed", "id": sse_id}) @@ -811,7 +811,7 @@ def _wait_for_ssh_info(self) -> bool: return False def _thread_main(self) -> None: - logger.info("Start WebRTC remote access service") + logger.info("启动WebRTC远程访问服务") try: if not self._wait_for_ssh_info(): self.info.error = "SSH fallback is not ready" @@ -830,7 +830,7 @@ def _thread_main(self) -> None: self._missing_dependency = str(e) self.info.error = str(e) self.info.connection_state = "dependency_missing" - logger.warning(f"WebRTC remote access disabled: {e}") + logger.warning(f"WebRTC远程访问已禁用: {e}") except RemoteSignalError as e: self.info.error = str(e) self.info.connection_state = "ssh_forward" @@ -839,7 +839,7 @@ def _thread_main(self) -> None: self.info.error = str(e) self.info.connection_state = "failed" logger.exception(e) - logger.info("Exit WebRTC remote access service thread") + logger.info("退出WebRTC远程访问服务线程") async def _run_signal_loop(self) -> None: try: @@ -893,7 +893,7 @@ async def _run_signal_loop(self) -> None: self.info.address = data.get("address") or self.info.address self.info.fallback_address = data.get("fallback_url") or self.info.fallback_address self.info.connection_state = "waiting_peer" - logger.info(f"P2P remote access url: {self.info.address}") + logger.info(f"P2P远程访问URL: {self.info.address}") elif msg_type == "offer": pc = RTCPeerConnection(configuration=rtc_config) peer_connections.add(pc) @@ -907,7 +907,7 @@ async def _run_signal_loop(self) -> None: @pc.on("datachannel") def on_datachannel(channel): - logger.info(f"P2P datachannel opened: {channel.label}") + logger.info(f"P2P数据通道已打开: {channel.label}") tunnel = WebRTCTunnel( _local_host(), State.deploy_config.WebuiPort, @@ -920,7 +920,7 @@ def on_message(message): try: payload = json.loads(message) except Exception as e: - logger.warning(f"P2P channel message parse failed: {e}") + logger.warning(f"P2P通道消息解析失败: {e}") return asyncio.create_task(tunnel.handle(payload)) @@ -964,7 +964,7 @@ async def on_connectionstatechange(): self.info.connection_state = state elif msg_type == "error": self.info.error = data.get("message", "") - logger.warning(f"P2P signaling error: {self.info.error}") + logger.warning(f"P2P信令错误: {self.info.error}") finally: keepalive_task.cancel() except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e: @@ -1064,7 +1064,7 @@ def get_error(self) -> str: def start_remote_access_service(**kwargs): """兼容旧调用入口。""" if kwargs: - logger.debug(f"Ignore legacy remote access kwargs: {kwargs}") + logger.debug(f"[WebUI-远程访问] 忽略旧版远程访问参数: {kwargs}") _provider.start() return True @@ -1078,7 +1078,7 @@ def keep_ssh_alive(): if _provider.is_alive(): yield continue - logger.info("Remote access service is not running, starting now") + logger.info("远程访问服务未运行,正在启动") try: start_remote_access_service() except ParseError as e: diff --git a/module/webui/setting.py b/module/webui/setting.py index d286e6396..dd0c731b3 100644 --- a/module/webui/setting.py +++ b/module/webui/setting.py @@ -1,3 +1,7 @@ +"""WebUI 设置与状态管理模块,维护界面偏好和持久化状态。 +包括主题配置、展开折叠状态、依赖同步标记, +以及预览资源路径定义和缓存管理机制。""" + # 此文件专门用于管理 Web 界面自身的偏好设置及持久化状态类文件。 # 包括界面主题、常用项展开折叠状态以及各类预览占位图、图标资源的路径定义与缓存管理机制。 import multiprocessing diff --git a/module/webui/translate.py b/module/webui/translate.py index 4cd1f2940..8e3f926cc 100644 --- a/module/webui/translate.py +++ b/module/webui/translate.py @@ -1,3 +1,7 @@ +"""WebUI 翻译编辑器,提供交互式界面逐条编辑各语言的翻译文本。 +支持 zh-CN、zh-TW、en-US、ja-JP 四种语言, +可筛选未翻译项并批量提交。""" + # 此模块提供 WebUI 内的 i18n 翻译编辑功能。 # 通过交互式表单逐条编辑各语言的翻译条目,支持筛选未翻译项。 from pywebio.input import (actions, checkbox, input, input_group, input_update, diff --git a/module/webui/updater.py b/module/webui/updater.py index dc10cbe5e..35024b326 100644 --- a/module/webui/updater.py +++ b/module/webui/updater.py @@ -1,3 +1,10 @@ +""" +Web界面更新管理器。 + +继承 DeployConfig 和 GitManager,提供 Alas 的自动更新、Git 操作、 +依赖同步和版本检查功能。通过后台线程执行更新任务。 +""" + import datetime import subprocess import threading @@ -83,16 +90,16 @@ def _check_update(self) -> bool: self.cloud_update_access_failed(fatal=False) return False if not cloud_update: - logger.info("Cloud update flag is false, skip update check") + logger.info("云更新标志为false,跳过更新检查") return False if State.deploy_config.GitOverCdn: status = self.goc_client.get_status() if status == "uptodate": - logger.info(f"No update") + logger.info(f"无更新") return False elif status == "behind": - logger.info(f"New update available") + logger.info(f"有新更新可用") return True else: # failed, should fallback to `git pull` @@ -105,7 +112,7 @@ def _check_update(self) -> bool: ): break else: - logger.warning("Git fetch failed") + logger.warning("Git获取失败") return False log = self.execute_output( @@ -113,18 +120,18 @@ def _check_update(self) -> bool: ) if log: logger.info( - f"Cannot find local commit {log.split()[0]} in upstream, skip update" + f"[WebUI-更新] 无法在上游找到本地提交 {log.split()[0]},跳过更新" ) return False sha1, _, _, message = self.get_commit(f"..{source}/{self.Branch}") if sha1: - logger.info(f"New update available") + logger.info(f"有新更新可用") logger.info(f"{sha1[:8]} - {message}") return True else: - logger.info(f"No update") + logger.info(f"无更新") return False def _check_update_(self) -> bool: @@ -158,23 +165,23 @@ def _check_update_(self) -> bool: ) except Exception as e: logger.exception(e) - logger.warning("Check update failed") + logger.warning("检查更新失败") return 0 if list_commit.status_code != 200: - logger.warning(f"Check update failed, code {list_commit.status_code}") + logger.warning(f"检查更新失败,状态码 {list_commit.status_code}") return 0 try: sha = list_commit.json()["commit"]["sha"] except Exception as e: logger.exception(e) - logger.warning("Check update failed when parsing return json") + logger.warning("解析返回JSON时检查更新失败") return 0 local_sha, _, _, _ = self._get_local_commit() if sha == local_sha: - logger.info("No update") + logger.info("无更新") return 0 try: @@ -186,17 +193,17 @@ def _check_update_(self) -> bool: ) except Exception as e: logger.exception(e) - logger.warning("Check update failed") + logger.warning("检查更新失败") return 0 if get_commit.status_code != 200: # for develops logger.info( - f"Cannot find local commit {local_sha[:8]} in upstream, skip update" + f"[WebUI-更新] 无法在上游找到本地提交 {local_sha[:8]},跳过更新" ) return 0 - logger.info(f"Update {sha[:8]} available") + logger.info(f"更新 {sha[:8]} 可用") return 1 def _check_update_thread(self): @@ -221,7 +228,7 @@ def git_install(self): return super().git_install() def update(self): - logger.hr("Run update") + logger.hr("[WebUI-更新] 执行更新") try: self.git_install() except ExecutionError: @@ -265,7 +272,7 @@ def _start_update(self) -> bool: for alas in instances: names.append(alas.config_name + "\n") - logger.info("Waiting all running alas finish.") + logger.info("[WebUI-更新] 等待所有运行中的 Alas 完成") return self._wait_update(instances, names) def _wait_update(self, instances: List[ProcessManager], names) -> bool: @@ -280,8 +287,8 @@ def _wait_update(self, instances: List[ProcessManager], names) -> bool: for alas in _instances: if not alas.alive: _instances.remove(alas) - logger.info(f"Alas [{alas.config_name}] stopped") - logger.info(f"Remains: {[alas.config_name for alas in _instances]}") + logger.info(f"[WebUI-更新] Alas [{alas.config_name}] 已停止") + logger.info(f"[WebUI-更新] 剩余: {[alas.config_name for alas in _instances]}") if self.state == "cancel": self.state = 1 self.event.clear() @@ -289,7 +296,7 @@ def _wait_update(self, instances: List[ProcessManager], names) -> bool: return True time.sleep(0.25) if time.time() - start_time > 60 * 10: - logger.warning("Waiting alas shutdown timeout, force kill") + logger.warning("[WebUI-更新] 等待 Alas 关闭超时,强制终止") failed = [] for alas in _instances: stopped = alas.stop() @@ -322,7 +329,7 @@ def _run_update(self, instances, names) -> bool: return False self.state = "run update" - logger.info("All alas stopped, start updating") + logger.info("[WebUI-更新] 所有 Alas 已停止,开始更新") # 更新前先持久化恢复计划。Git 的 reset/pull 即使报错也可能已修改源码, # 因而一旦开始更新,worker 只能由父进程完成依赖同步后恢复。 @@ -350,7 +357,7 @@ def _run_update(self, instances, names) -> bool: # Git 更新失败时不能假定工作树保持旧版本:git reset/pull 可能已部分完成。 # 保留已写入的同步和恢复计划,交由父进程以一致环境重启。 self.state = "failed" - logger.warning("更新失败,将由父进程完成依赖同步后重启") + logger.warning("[WebUI-更新] 更新失败,将由父进程完成依赖同步后重启") try: State._restart_requested = True @@ -369,7 +376,7 @@ def _run_update(self, instances, names) -> bool: cleaned = clearup() if cleaned is False: - logger.warning("WebUI 清理未完成,将由父进程终止完整进程树后再重启") + logger.warning("[WebUI-更新] WebUI 清理未完成,将由父进程终止完整进程树后再重启") except Exception as exc: logger.exception_context( title='WebUI 清理失败,继续重启', diff --git a/module/webui/utils.py b/module/webui/utils.py index 4ff032bf6..cb1e3d6a3 100644 --- a/module/webui/utils.py +++ b/module/webui/utils.py @@ -1,3 +1,7 @@ +"""WebUI 底层工具函数集,提供 LocalStorage 读写、JS 注入执行、 +CSS 样式管理、时间格式转换等功能, +以及维持 UI 刷新的任务调度控制器。""" + # 此文件提供了 WebUI 相关的底层工具函数。 # 包含 LocalStorage 读写、JavaScript 代码注入执行、CSS 样式管理、时间格式转换以及维持 UI 刷新的任务调度控制器。 import datetime @@ -160,9 +164,9 @@ def add_task(self, task: Task, pending_delete: bool = False) -> None: 添加后台运行的任务。 """ if task in self.tasks: - logger.warning(f"Task {task} already in tasks list.") + logger.warning(f"[WebUI-工具] 任务 {task} 已在任务列表中") return - logger.info(f"Add task {task}") + logger.info(f"添加任务 {task}") with self._lock: self.tasks.append(task) if pending_delete: @@ -171,10 +175,10 @@ def add_task(self, task: Task, pending_delete: bool = False) -> None: def _remove_task(self, task: Task) -> None: if task in self.tasks: self.tasks.remove(task) - logger.info(f"Task {task} removed.") + logger.info(f"[WebUI-工具] 任务 {task} 已移除") else: logger.warning( - f"Failed to remove task {task}. Current tasks list: {self.tasks}" + f"[WebUI-工具] 移除任务 {task} 失败。当前任务列表: {self.tasks}" ) def remove_task(self, task: Task, nowait: bool = False) -> None: @@ -246,7 +250,7 @@ def loop(self) -> None: time.sleep(0.05) else: time.sleep(0.5) - logger.info("End of task handler loop") + logger.info("任务处理循环结束") def _get_thread(self) -> threading.Thread: thread = threading.Thread(target=self.loop, daemon=True) @@ -256,9 +260,9 @@ def start(self) -> None: """ 启动任务处理器。 """ - logger.info("Start task handler") + logger.info("启动任务处理") if self._thread is not None and self._thread.is_alive(): - logger.warning("Task handler already running!") + logger.warning("[WebUI-工具] 任务处理器已在运行!") return self._thread = self._get_thread() self._thread.start() @@ -273,7 +277,7 @@ def stop(self) -> bool: if threading.current_thread() is not self._thread: self._thread.join(timeout=2) if not self._thread.is_alive(): - logger.info("Finish task handler") + logger.info("完成任务处理") return True else: logger.warning("[WebUI] 任务处理器未在 2 秒内停止") @@ -699,7 +703,7 @@ def get_next_time(t: datetime.time): def on_task_exception(self): - logger.exception("An internal error occurred in the application") + logger.exception("[WebUI-工具] 应用发生内部错误") toast_msg = ( "应用发生内部错误" if "zh" in session_info.user_language diff --git a/module/webui/widgets.py b/module/webui/widgets.py index 38ecd55d3..6b5579951 100644 --- a/module/webui/widgets.py +++ b/module/webui/widgets.py @@ -1,3 +1,7 @@ +"""WebUI 自定义交互组件库,定义各种高度定制化的可视化控件。 +包括彩色实时日志渲染器(RichLog)、状态感知切换按钮、 +图标按钮组和任务队列编辑器等组件。""" + import copy # 此文件定义了 WebUI 中使用的各种自定义交互图形组件(Widgets)。 # 包含彩色实时日志渲染器(RichLog)、状态感知切换按钮以及图标按钮组等高度定制化的可视化组件。 From 2aa4f07b9671962175365fd24f7a2ee758e30299 Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Sun, 26 Jul 2026 23:55:27 +0800 Subject: [PATCH 4/7] fix --- module/webui/process_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/webui/process_manager.py b/module/webui/process_manager.py index ab3546ed9..ac16ac4c9 100644 --- a/module/webui/process_manager.py +++ b/module/webui/process_manager.py @@ -223,7 +223,7 @@ def stop(self) -> bool: stopped = self._unregister_process() if stopped and pid is not None: self.renderables.append( - Text(f"[{self.config_name}] 已退出。原因:手动停止\n") + Text(f"[{self.config_name}] exited. Reason: Manual stop\n") ) if not stopped: logger.error(f"[{self.config_name}] 停止工作进程失败 PID {pid}") @@ -695,9 +695,9 @@ def run_process( f"[WebUI] 杂鱼大叔,连功能模块都找不到吗?{func} 这种东西根本不存在啦~" ) if e is not None and e.is_set(): - logger.info(f"[{config_name}] 已退出。原因:更新\n") + logger.info(f"[{config_name}] exited. Reason: Update\n") else: - logger.info(f"[{config_name}] 已退出。原因:完成\n") + logger.info(f"[{config_name}] exited. Reason: Finish\n") except Exception as ex: logger.exception(ex) From 3223ebfcf4dad45773fb97f5f2418726fedee858 Mon Sep 17 00:00:00 2001 From: longer-sausage Date: Mon, 27 Jul 2026 01:36:52 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8Dui=E7=BF=BB?= =?UTF-8?q?=E9=A1=B5=E6=97=B6=E5=8F=AF=E8=83=BD=E8=AF=AF=E5=88=A4=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/ui/ui.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/module/ui/ui.py b/module/ui/ui.py index 12be5b715..d809b1345 100644 --- a/module/ui/ui.py +++ b/module/ui/ui.py @@ -378,7 +378,6 @@ def ui_ensure_index( skip_first_screenshot=False, fast=True, interval=(0.2, 0.3), - stall_tolerance=10, ): """ 确保翻页到指定索引位置,通过 OCR 识别当前页码并点击翻页按钮。 @@ -391,13 +390,9 @@ def ui_ensure_index( skip_first_screenshot (bool): 是否跳过首次截图。 fast (bool): 默认为 True。当索引不连续时设为 False。 interval (tuple, int, float): 两次点击之间的间隔(秒)。 - stall_tolerance (int): 允许索引连续不变的次数,超过后判定为无法达到目标并退出。 """ logger.hr("UI 确保索引") retry = Timer(1, count=2) - # 进度停滞检测:记录上一次索引值和连续不变次数,防止因游戏端限制导致死循环 - last_current = None - stall_count = 0 while 1: if skip_first_screenshot: skip_first_screenshot = False @@ -414,17 +409,6 @@ def ui_ensure_index( if diff == 0: break - # 进度停滞检测:当连续stall_tolerance次索引值未变化且diff不为0时退出 - if current == last_current: - stall_count += 1 - else: - stall_count = 0 - last_current = current - if stall_count >= stall_tolerance: - logger.warning(f'[UI] 索引停滞在 {current} 已 {stall_count} 次,' - f'无法达到目标 {index},跳过') - break - if retry.reached(): button = next_button if diff > 0 else prev_button if fast: From da216986e56ef4784328530d467cf420f49cbfd0 Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Mon, 27 Jul 2026 11:16:54 +0800 Subject: [PATCH 6/7] Fix --- module/awaken/assets.py | 2 -- module/azur_stats/assets.py | 2 -- module/campaign/assets.py | 2 -- module/coalition/assets.py | 2 -- module/combat/assets.py | 2 -- module/combat_ui/assets.py | 2 -- module/commission/assets.py | 2 -- module/config/config_generated.py | 6 ------ module/config/i18n/en-US.json | 18 +----------------- module/config/i18n/ja-JP.json | 18 +----------------- module/config/i18n/zh-CN.json | 14 +------------- module/config/i18n/zh-MIAO.json | 14 +------------- module/config/i18n/zh-TW.json | 22 +++++----------------- module/daily/assets.py | 2 -- module/dorm/assets.py | 2 -- module/equipment/assets.py | 2 -- module/event/assets.py | 2 -- module/event_hospital/assets.py | 2 -- module/eventstory/assets.py | 2 -- module/exercise/assets.py | 2 -- module/freebies/assets.py | 2 -- module/gacha/assets.py | 2 -- module/guild/assets.py | 2 -- module/handler/assets.py | 2 -- module/hard/assets.py | 2 -- module/island/assets.py | 2 -- module/island_business/assets.py | 2 -- module/island_cargo_preparation/assets.py | 2 -- module/island_daily_interact/assets.py | 2 -- module/island_daily_order/assets.py | 2 -- module/island_farm/assets.py | 2 -- module/island_fishery/assets.py | 2 -- module/island_grill/assets.py | 2 -- module/island_juu_coffee/assets.py | 2 -- module/island_juu_eatery/assets.py | 2 -- module/island_manufacture/assets.py | 2 -- module/island_mine_forest/assets.py | 2 -- module/island_pearl_sell/assets.py | 2 -- module/island_rancher/assets.py | 2 -- module/island_restaurant/assets.py | 2 -- module/island_select_character/assets.py | 2 -- module/island_teahouse/assets.py | 2 -- module/map/assets.py | 2 -- module/meowfficer/assets.py | 2 -- module/meta_reward/assets.py | 2 -- module/minigame/assets.py | 2 -- module/os/assets.py | 2 -- module/os_ash/assets.py | 2 -- module/os_combat/assets.py | 2 -- module/os_handler/assets.py | 2 -- module/os_shop/assets.py | 2 -- module/private_quarters/assets.py | 2 -- module/raid/assets.py | 2 -- module/research/assets.py | 2 -- module/retire/assets.py | 2 -- module/reward/assets.py | 2 -- module/shipyard/assets.py | 2 -- module/shop/assets.py | 2 -- module/shop_event/assets.py | 2 -- module/sos/assets.py | 2 -- module/statistics/assets.py | 2 -- module/storage/assets.py | 2 -- module/tactical/assets.py | 2 -- module/template/assets.py | 2 -- module/ui/assets.py | 2 -- module/ui_white/assets.py | 2 -- module/war_archives/assets.py | 2 -- 67 files changed, 9 insertions(+), 205 deletions(-) diff --git a/module/awaken/assets.py b/module/awaken/assets.py index 4a2598179..6eeca9619 100644 --- a/module/awaken/assets.py +++ b/module/awaken/assets.py @@ -1,5 +1,3 @@ -"""觉醒模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/azur_stats/assets.py b/module/azur_stats/assets.py index 8715cebe3..ceb5a06a2 100644 --- a/module/azur_stats/assets.py +++ b/module/azur_stats/assets.py @@ -1,5 +1,3 @@ -"""AzurStats统计模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/campaign/assets.py b/module/campaign/assets.py index 967e7d6b9..bdc6742c2 100644 --- a/module/campaign/assets.py +++ b/module/campaign/assets.py @@ -1,5 +1,3 @@ -"""战役模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/coalition/assets.py b/module/coalition/assets.py index dec57a221..1b1036f3c 100644 --- a/module/coalition/assets.py +++ b/module/coalition/assets.py @@ -1,5 +1,3 @@ -"""联动模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/combat/assets.py b/module/combat/assets.py index 032a3d6cb..b6eb2d1e1 100644 --- a/module/combat/assets.py +++ b/module/combat/assets.py @@ -1,5 +1,3 @@ -"""战斗模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/combat_ui/assets.py b/module/combat_ui/assets.py index 25deb783d..ef6ac985c 100644 --- a/module/combat_ui/assets.py +++ b/module/combat_ui/assets.py @@ -1,5 +1,3 @@ -"""战斗UI模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/commission/assets.py b/module/commission/assets.py index 8518101be..c643e1bba 100644 --- a/module/commission/assets.py +++ b/module/commission/assets.py @@ -1,5 +1,3 @@ -"""委托模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/config/config_generated.py b/module/config/config_generated.py index 10278f04d..9f7bea819 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -1,9 +1,3 @@ -"""自动生成的配置类。 - -由 config_updater.py 自动生成,包含所有配置项的默认值定义。 -提供 IDE 自动补全支持,不应手动修改此文件。 -""" - # 此文件是配置系统的更新器。 # 负责读取配置定义、生成 config_generated.py 以及处理配置的版本迁移、i18n 生成等核心管理任务。 import datetime diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 98d3d5292..36a0fbf73 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -401,18 +401,6 @@ "EmulatorManager": { "name": "Emulator Manager", "help": "Provides an entry point for manually restarting the emulator." - }, - "BattlePass": { - "name": "Battle Pass Plus", - "help": "" - }, - "CoreShop": { - "name": "Core Shop Plus", - "help": "" - }, - "MeowfficerTrain": { - "name": "Meowfficer Training Plus", - "help": "" } }, "Scheduler": { @@ -3472,10 +3460,6 @@ "TaskPriority": { "name": "Replenishment Task Priority", "help": "The priority order of tasks for yellow coin replenishment when they are insufficient. Separated by >." - }, - "APreserveUntilReset": { - "name": "Force Consume Action Points at Month-End Reset", - "help": "Auto-set reserved action points to 0 and consume remaining action points before Operation Siren reset\nRecommended to use with Corrosion 1 Leveling" } }, "OpsiPreventActionPointOverflow": { @@ -6353,4 +6337,4 @@ "ButtonCreate": "Create & Finish" } } -} +} \ No newline at end of file diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 0a914d00f..b06b7c880 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -401,18 +401,6 @@ "EmulatorManager": { "name": "エミュレータマネージャー", "help": "エミュレータを手動で再起動するためのエントリポイントを提供します。" - }, - "MeowfficerTrain": { - "name": "オフニャ訓練Plus", - "help": "" - }, - "BattlePass": { - "name": "シーズンパスPlus", - "help": "" - }, - "CoreShop": { - "name": "コアショップPlus", - "help": "" } }, "Scheduler": { @@ -3472,10 +3460,6 @@ "TaskPriority": { "name": "作戦コイン補充優先度", "help": "作戦コイン(黄)が不足している場合、作戦コイン補充任務の実行優先順位。> で区切ります。" - }, - "APreserveUntilReset": { - "name": "月末リセット時に行動力を強制消費", - "help": "セイレーン作戦リセット前に、保留行動力を自動的に0に設定し、残余行動力を消費します。侵蝕1レベリングとの併用を推奨します。" } }, "OpsiPreventActionPointOverflow": { @@ -6353,4 +6337,4 @@ "ButtonCreate": "作成して完了" } } -} +} \ No newline at end of file diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index be8b1b568..f51353897 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -401,18 +401,6 @@ "EmulatorManager": { "name": "模拟器管理器", "help": "提供手动重启模拟器的入口" - }, - "BattlePass": { - "name": "大月卡Plus", - "help": "" - }, - "CoreShop": { - "name": "核心兑换Plus", - "help": "" - }, - "MeowfficerTrain": { - "name": "指挥喵训练Plus", - "help": "" } }, "Scheduler": { @@ -6349,4 +6337,4 @@ "ButtonCreate": "创建并完成" } } -} +} \ No newline at end of file diff --git a/module/config/i18n/zh-MIAO.json b/module/config/i18n/zh-MIAO.json index 4499b40df..d77ac151d 100644 --- a/module/config/i18n/zh-MIAO.json +++ b/module/config/i18n/zh-MIAO.json @@ -401,18 +401,6 @@ "EmulatorManager": { "name": "模拟器管理器", "help": "提供手动重启模拟器的入口喵~ (=^・ω・^=)" - }, - "BattlePass": { - "name": "大月卡喵", - "help": "领取大月卡奖励喵~ (´・ω・`)" - }, - "CoreShop": { - "name": "核心兑换喵", - "help": "到核心商店兑换需要的物品喵~ (・ω・)" - }, - "MeowfficerTrain": { - "name": "指挥喵训练喵", - "help": "安排指挥喵训练,别让小猫偷懒喵~ (=^・ω・^=)" } }, "Scheduler": { @@ -6349,4 +6337,4 @@ "ButtonCreate": "创建并完成喵!" } } -} +} \ No newline at end of file diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 0b40637f5..18ac13b57 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -401,18 +401,6 @@ "EmulatorManager": { "name": "模擬器管理器", "help": "提供手動重啟模擬器的入口" - }, - "BattlePass": { - "name": "大月卡Plus", - "help": "" - }, - "CoreShop": { - "name": "核心兌換Plus", - "help": "" - }, - "MeowfficerTrain": { - "name": "指揮喵訓練Plus", - "help": "" } }, "Scheduler": { @@ -1092,7 +1080,7 @@ "event_20200326_cn": "Microlayer Medley", "event_20200423_cn": "Crimson Echoes Rerun", "event_20200507_cn": "The Way Home in the Night", - "event_20200521_cn": "穹頂下的聖詠曲", + "event_20200521_cn": "穹顶下的圣咏曲", "event_20200521_en": "Iris of Light and Dark Rerun", "event_20200603_cn": "復刻峽灣間的反擊", "event_20200603_en": "Prelude under the Moon Rerun", @@ -1146,7 +1134,7 @@ "event_20230803_cn": "奏響鳶尾之歌", "event_20230817_cn": "愚者的天平", "event_20230914_cn": "須臾望月抄", - "event_20231026_cn": "颶風與青春之泉", + "event_20231026_cn": "飓風與青春之泉", "event_20231123_cn": "蒼閃忍法帖", "event_20231221_cn": "星海逐光", "event_20240229_cn": "雪境迷蹤", @@ -1171,7 +1159,7 @@ "event_20260326_cn": "漫遊者招募計劃", "event_20260417_cn": "假日航線閃耀海濱", "event_20260520_cn": "聖印前的同盟", - "event_20260625_cn": "美夢巡演奇妙夜", + "event_20260625_cn": "美夢巡演奇秒夜", "raid_20200624": "特別演習埃塞克斯級(復刻)", "raid_20210708": "復刻穿越彼方的水線", "raid_20220127": "演習神秘事件調查", @@ -1229,7 +1217,7 @@ "war_archives_20230223_cn": "檔案 湮燼塵墟", "war_archives_20230525_cn": "檔案 空相交會點", "war_archives_20230803_cn": "檔案 奏響鳶尾之歌", - "war_archives_20231026_cn": "檔案 颶風與青春之泉", + "war_archives_20231026_cn": "檔案 飓風與青春之泉", "war_archives_20240725_cn": "檔案 幻夢間奏曲" }, "Mode": { @@ -6349,4 +6337,4 @@ "ButtonCreate": "建立並完成" } } -} +} \ No newline at end of file diff --git a/module/daily/assets.py b/module/daily/assets.py index 12076597c..599443350 100644 --- a/module/daily/assets.py +++ b/module/daily/assets.py @@ -1,5 +1,3 @@ -"""每日模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/dorm/assets.py b/module/dorm/assets.py index 6a21e61eb..f6f60d49c 100644 --- a/module/dorm/assets.py +++ b/module/dorm/assets.py @@ -1,5 +1,3 @@ -"""后宅模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/equipment/assets.py b/module/equipment/assets.py index 6d7676754..e66d78fa1 100644 --- a/module/equipment/assets.py +++ b/module/equipment/assets.py @@ -1,5 +1,3 @@ -"""装备模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/event/assets.py b/module/event/assets.py index 106dad63f..7bd468d10 100644 --- a/module/event/assets.py +++ b/module/event/assets.py @@ -1,5 +1,3 @@ -"""活动模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/event_hospital/assets.py b/module/event_hospital/assets.py index c35d94460..55fa9fdf7 100644 --- a/module/event_hospital/assets.py +++ b/module/event_hospital/assets.py @@ -1,5 +1,3 @@ -"""医院活动模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/eventstory/assets.py b/module/eventstory/assets.py index b55a9dcfc..df477737a 100644 --- a/module/eventstory/assets.py +++ b/module/eventstory/assets.py @@ -1,5 +1,3 @@ -"""活动剧情模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/exercise/assets.py b/module/exercise/assets.py index 57dad7866..fcb325b41 100644 --- a/module/exercise/assets.py +++ b/module/exercise/assets.py @@ -1,5 +1,3 @@ -"""演习模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/freebies/assets.py b/module/freebies/assets.py index 31bf3fe95..b8c92d869 100644 --- a/module/freebies/assets.py +++ b/module/freebies/assets.py @@ -1,5 +1,3 @@ -"""免费福利模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/gacha/assets.py b/module/gacha/assets.py index 57c9a3fb6..efb61b91d 100644 --- a/module/gacha/assets.py +++ b/module/gacha/assets.py @@ -1,5 +1,3 @@ -"""建造模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/guild/assets.py b/module/guild/assets.py index 1e0fda01c..9eef24a2f 100644 --- a/module/guild/assets.py +++ b/module/guild/assets.py @@ -1,5 +1,3 @@ -"""大舰队模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/handler/assets.py b/module/handler/assets.py index 5680ef8bf..6f0222262 100644 --- a/module/handler/assets.py +++ b/module/handler/assets.py @@ -1,5 +1,3 @@ -"""处理器模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/hard/assets.py b/module/hard/assets.py index 327cf5d9c..4fd1c45c8 100644 --- a/module/hard/assets.py +++ b/module/hard/assets.py @@ -1,5 +1,3 @@ -"""困难模式模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island/assets.py b/module/island/assets.py index 12ee67171..5db1d9697 100644 --- a/module/island/assets.py +++ b/module/island/assets.py @@ -1,5 +1,3 @@ -"""岛屿模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_business/assets.py b/module/island_business/assets.py index 6dd1d59d8..8e4e7ddbc 100644 --- a/module/island_business/assets.py +++ b/module/island_business/assets.py @@ -1,5 +1,3 @@ -"""岛屿生意模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_cargo_preparation/assets.py b/module/island_cargo_preparation/assets.py index 879653369..8525f61f5 100644 --- a/module/island_cargo_preparation/assets.py +++ b/module/island_cargo_preparation/assets.py @@ -1,5 +1,3 @@ -"""岛屿货物准备模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_daily_interact/assets.py b/module/island_daily_interact/assets.py index cf50a4f6d..17a469e35 100644 --- a/module/island_daily_interact/assets.py +++ b/module/island_daily_interact/assets.py @@ -1,5 +1,3 @@ -"""岛屿每日互动模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_daily_order/assets.py b/module/island_daily_order/assets.py index 16f641fc5..3fd71dc99 100644 --- a/module/island_daily_order/assets.py +++ b/module/island_daily_order/assets.py @@ -1,5 +1,3 @@ -"""岛屿每日订单模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_farm/assets.py b/module/island_farm/assets.py index 83b2383fb..85015c5ae 100644 --- a/module/island_farm/assets.py +++ b/module/island_farm/assets.py @@ -1,5 +1,3 @@ -"""岛屿农场模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_fishery/assets.py b/module/island_fishery/assets.py index c5d66a9f2..2636756d1 100644 --- a/module/island_fishery/assets.py +++ b/module/island_fishery/assets.py @@ -1,5 +1,3 @@ -"""岛屿渔业模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_grill/assets.py b/module/island_grill/assets.py index 854b93146..08185a6c9 100644 --- a/module/island_grill/assets.py +++ b/module/island_grill/assets.py @@ -1,5 +1,3 @@ -"""岛屿烧烤模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_juu_coffee/assets.py b/module/island_juu_coffee/assets.py index 193365583..dac0bd35e 100644 --- a/module/island_juu_coffee/assets.py +++ b/module/island_juu_coffee/assets.py @@ -1,5 +1,3 @@ -"""岛屿咖啡模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_juu_eatery/assets.py b/module/island_juu_eatery/assets.py index 64016d3d1..ff46c9273 100644 --- a/module/island_juu_eatery/assets.py +++ b/module/island_juu_eatery/assets.py @@ -1,5 +1,3 @@ -"""岛屿餐厅模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_manufacture/assets.py b/module/island_manufacture/assets.py index a9704e913..173fac165 100644 --- a/module/island_manufacture/assets.py +++ b/module/island_manufacture/assets.py @@ -1,5 +1,3 @@ -"""岛屿制造模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_mine_forest/assets.py b/module/island_mine_forest/assets.py index 34afb0200..758539726 100644 --- a/module/island_mine_forest/assets.py +++ b/module/island_mine_forest/assets.py @@ -1,5 +1,3 @@ -"""岛屿矿林模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_pearl_sell/assets.py b/module/island_pearl_sell/assets.py index b406451d3..4791926fc 100644 --- a/module/island_pearl_sell/assets.py +++ b/module/island_pearl_sell/assets.py @@ -1,5 +1,3 @@ -"""岛屿珍珠模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_rancher/assets.py b/module/island_rancher/assets.py index 62f73a041..9f213fb7b 100644 --- a/module/island_rancher/assets.py +++ b/module/island_rancher/assets.py @@ -1,5 +1,3 @@ -"""岛屿牧场模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_restaurant/assets.py b/module/island_restaurant/assets.py index f955917ec..1b7c2248a 100644 --- a/module/island_restaurant/assets.py +++ b/module/island_restaurant/assets.py @@ -1,5 +1,3 @@ -"""岛屿饭店模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_select_character/assets.py b/module/island_select_character/assets.py index ed68071db..c6ab8e4e9 100644 --- a/module/island_select_character/assets.py +++ b/module/island_select_character/assets.py @@ -1,5 +1,3 @@ -"""岛屿角色选择模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/island_teahouse/assets.py b/module/island_teahouse/assets.py index 6b3c9d438..4a436e323 100644 --- a/module/island_teahouse/assets.py +++ b/module/island_teahouse/assets.py @@ -1,5 +1,3 @@ -"""岛屿茶馆模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/map/assets.py b/module/map/assets.py index 730c0d3c4..81f2e01e0 100644 --- a/module/map/assets.py +++ b/module/map/assets.py @@ -1,5 +1,3 @@ -"""地图模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/meowfficer/assets.py b/module/meowfficer/assets.py index a4d042445..5bedca480 100644 --- a/module/meowfficer/assets.py +++ b/module/meowfficer/assets.py @@ -1,5 +1,3 @@ -"""指挥喵模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/meta_reward/assets.py b/module/meta_reward/assets.py index 0a68c3e51..2b7285014 100644 --- a/module/meta_reward/assets.py +++ b/module/meta_reward/assets.py @@ -1,5 +1,3 @@ -"""META奖励模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/minigame/assets.py b/module/minigame/assets.py index 5c156aec6..a88971560 100644 --- a/module/minigame/assets.py +++ b/module/minigame/assets.py @@ -1,5 +1,3 @@ -"""小游戏模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/os/assets.py b/module/os/assets.py index 643df27c3..1ff405589 100644 --- a/module/os/assets.py +++ b/module/os/assets.py @@ -1,5 +1,3 @@ -"""大世界模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/os_ash/assets.py b/module/os_ash/assets.py index eeabc9feb..0323b4685 100644 --- a/module/os_ash/assets.py +++ b/module/os_ash/assets.py @@ -1,5 +1,3 @@ -"""大世界余烬模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/os_combat/assets.py b/module/os_combat/assets.py index 524ca10af..368fcf6bb 100644 --- a/module/os_combat/assets.py +++ b/module/os_combat/assets.py @@ -1,5 +1,3 @@ -"""大世界战斗模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/os_handler/assets.py b/module/os_handler/assets.py index 4db54f494..0e64afcf1 100644 --- a/module/os_handler/assets.py +++ b/module/os_handler/assets.py @@ -1,5 +1,3 @@ -"""大世界处理器模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/os_shop/assets.py b/module/os_shop/assets.py index d9fe08375..cf55586e6 100644 --- a/module/os_shop/assets.py +++ b/module/os_shop/assets.py @@ -1,5 +1,3 @@ -"""大世界商店模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/private_quarters/assets.py b/module/private_quarters/assets.py index d73945c00..44ff56bd4 100644 --- a/module/private_quarters/assets.py +++ b/module/private_quarters/assets.py @@ -1,5 +1,3 @@ -"""私人休息室模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/raid/assets.py b/module/raid/assets.py index 5ff46026b..109b6a07d 100644 --- a/module/raid/assets.py +++ b/module/raid/assets.py @@ -1,5 +1,3 @@ -"""突袭模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/research/assets.py b/module/research/assets.py index 1f737998b..e07460263 100644 --- a/module/research/assets.py +++ b/module/research/assets.py @@ -1,5 +1,3 @@ -"""科研模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/retire/assets.py b/module/retire/assets.py index 8ed305022..4b226ccce 100644 --- a/module/retire/assets.py +++ b/module/retire/assets.py @@ -1,5 +1,3 @@ -"""退役模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/reward/assets.py b/module/reward/assets.py index 738e9528e..0a75ce3ae 100644 --- a/module/reward/assets.py +++ b/module/reward/assets.py @@ -1,5 +1,3 @@ -"""奖励模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/shipyard/assets.py b/module/shipyard/assets.py index f28e7f5cd..108b20043 100644 --- a/module/shipyard/assets.py +++ b/module/shipyard/assets.py @@ -1,5 +1,3 @@ -"""船坞模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/shop/assets.py b/module/shop/assets.py index bddfbbc15..a5279db8b 100644 --- a/module/shop/assets.py +++ b/module/shop/assets.py @@ -1,5 +1,3 @@ -"""商店模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/shop_event/assets.py b/module/shop_event/assets.py index 30d2c81f9..413d2f786 100644 --- a/module/shop_event/assets.py +++ b/module/shop_event/assets.py @@ -1,5 +1,3 @@ -"""活动商店模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/sos/assets.py b/module/sos/assets.py index 3815fd6ae..18fcd5b3f 100644 --- a/module/sos/assets.py +++ b/module/sos/assets.py @@ -1,5 +1,3 @@ -"""SOS模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/statistics/assets.py b/module/statistics/assets.py index a46a1cd3e..50213574b 100644 --- a/module/statistics/assets.py +++ b/module/statistics/assets.py @@ -1,5 +1,3 @@ -"""统计模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/storage/assets.py b/module/storage/assets.py index b764fee1d..813885957 100644 --- a/module/storage/assets.py +++ b/module/storage/assets.py @@ -1,5 +1,3 @@ -"""仓库模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/tactical/assets.py b/module/tactical/assets.py index c7abdfb4b..ea367189e 100644 --- a/module/tactical/assets.py +++ b/module/tactical/assets.py @@ -1,5 +1,3 @@ -"""战术学院模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/template/assets.py b/module/template/assets.py index 3ce7b8eda..2082ebe53 100644 --- a/module/template/assets.py +++ b/module/template/assets.py @@ -1,5 +1,3 @@ -"""模板模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/ui/assets.py b/module/ui/assets.py index 9f36b556c..5a18dd6d3 100644 --- a/module/ui/assets.py +++ b/module/ui/assets.py @@ -1,5 +1,3 @@ -"""UI模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/ui_white/assets.py b/module/ui_white/assets.py index 5e8803f3c..49d1087f2 100644 --- a/module/ui_white/assets.py +++ b/module/ui_white/assets.py @@ -1,5 +1,3 @@ -"""UI白色主题模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template diff --git a/module/war_archives/assets.py b/module/war_archives/assets.py index 65747ab67..873db5b5c 100644 --- a/module/war_archives/assets.py +++ b/module/war_archives/assets.py @@ -1,5 +1,3 @@ -"""作战档案模块的 UI 资源定义。""" - from module.base.button import Button from module.base.template import Template From 0e00a97f21cbd50e65a2feeadcb8ef4ecc6f2db4 Mon Sep 17 00:00:00 2001 From: wess09 <> Date: Mon, 27 Jul 2026 12:12:28 +0800 Subject: [PATCH 7/7] Readme --- .github/scripts/build_git_over_cdn_eo_esa.mjs | 42 ++++++++++++++++++- README.md | 22 +++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/scripts/build_git_over_cdn_eo_esa.mjs b/.github/scripts/build_git_over_cdn_eo_esa.mjs index 7b4055adf..584ed8636 100644 --- a/.github/scripts/build_git_over_cdn_eo_esa.mjs +++ b/.github/scripts/build_git_over_cdn_eo_esa.mjs @@ -6,6 +6,10 @@ import { spawn, spawnSync } from "node:child_process"; import yazl from "yazl"; const env = process.env; +const MAIN_SITE_URL = "https://alas.nanoda.work/"; +const MAIN_SITE_HOST = "alas.nanoda.work"; +const SEO_TITLE = "AzurPilot 更新 CDN - 碧蓝航线自动化更新镜像"; +const SEO_DESCRIPTION = "AzurPilot 更新 CDN 提供 Git over CDN 静态更新文件、latest.json、更新包状态与最近提交信息,主站为 https://alas.nanoda.work/。"; function printHelp() { console.log(`构建 EO/ESA/Pages 可用的 git-over-cdn 静态更新文件。 @@ -301,6 +305,10 @@ function escapeHtml(value) { .replaceAll("'", "'"); } +function escapeScriptJson(value) { + return String(value).replaceAll("<", "\\u003c"); +} + function shortCommit(commit) { return commit.slice(0, 8); } @@ -348,6 +356,20 @@ function writeIndexHtml(outputDir, options, latest, oldCommits, commitInfos) { const generatedAtTimestamp = Date.now(); const generatedAt = new Date(generatedAtTimestamp).toISOString(); const commitAge = formatDuration(generatedAtTimestamp - latestCommitInfo.committedAtTimestamp); + const structuredData = escapeScriptJson(JSON.stringify({ + "@context": "https://schema.org", + "@type": "WebPage", + name: SEO_TITLE, + description: SEO_DESCRIPTION, + isPartOf: { + "@type": "WebSite", + name: "AzurPilot", + url: MAIN_SITE_URL, + }, + about: "AzurPilot Git over CDN 更新镜像", + relatedLink: MAIN_SITE_URL, + dateModified: generatedAt, + }, null, 2)); const packRows = oldCommits.map((commit) => { const filename = `${latest}/${commit}.zip`; return ` @@ -373,7 +395,21 @@ function writeIndexHtml(outputDir, options, latest, oldCommits, commitInfos) { - AzurPilot 更新 CDN + ${escapeHtml(SEO_TITLE)} + + + + + + + + + + + + + +