From 2c0104b92e5901738adaa86baf97fdcb88c0ac28 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Sun, 26 Jul 2026 18:57:50 +0800 Subject: [PATCH 01/14] feat(emulator): improve scrcpy controller with clipboard and touch support - Add SET_CLIPBOARD support for multi-byte (Chinese) text input - Use INJECT_TEXT for ASCII, SET_CLIPBOARD+paste for non-ASCII - Refine touch event injection format - Update controller tests --- autowsgr/emulator/controller/scrcpy.py | 245 +++++++++++++++++++++++-- testing/emulator/test_controller.py | 171 +++++++++++++---- 2 files changed, 363 insertions(+), 53 deletions(-) diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index b52848d9..f856fc16 100644 --- a/autowsgr/emulator/controller/scrcpy.py +++ b/autowsgr/emulator/controller/scrcpy.py @@ -1,7 +1,9 @@ """scrcpy 设备控制器 — 基于 adbutils + scrcpy-server 的实现。 -通过 scrcpy 协议获取 H264 视频流并解码为截图,使用 adbutils 执行 -ADB 命令实现触控/按键等操作。 +通过 scrcpy 协议获取 H264 视频流并解码为截图;触控(click/swipe/long_tap)、 +按键和文本输入通过 scrcpy 控制流(INJECT_TOUCH_EVENT / INJECT_KEYCODE / +INJECT_TEXT)实现,延迟远低于 ``adb shell input``。仅应用启停、运行检测等 +非实时操作仍走 ADB。 使用方式:: @@ -17,6 +19,8 @@ from __future__ import annotations import random +import socket +import struct import threading import time from pathlib import Path @@ -31,8 +35,6 @@ if TYPE_CHECKING: - import socket - import numpy as np from adbutils import AdbConnection, AdbDevice @@ -43,12 +45,40 @@ _SCRCPY_SERVER_VERSION = '2.7' _DEVICE_JAR_PATH = '/data/local/tmp/scrcpy-server.jar' +# ── scrcpy 2.7 控制流协议常量 ── +# 参考:scrcpy app/src/control_msg.h / control_msg.c (v2.7) + +# 控制消息类型 +_TYPE_INJECT_KEYCODE = 0 +_TYPE_INJECT_TEXT = 1 +_TYPE_INJECT_TOUCH_EVENT = 2 +_TYPE_INJECT_SCROLL_EVENT = 3 +_TYPE_SET_CLIPBOARD = 9 + +# SET_CLIPBOARD 文本上限(SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH = 1<<18 - 14) +_CLIPBOARD_TEXT_MAX_LENGTH = (1 << 18) - 14 + +# Android MotionEvent 动作码 +_ACTION_DOWN = 0 +_ACTION_UP = 1 +_ACTION_MOVE = 2 + +# Android KeyEvent 动作码 +_KEY_ACTION_DOWN = 0 +_KEY_ACTION_UP = 1 + +# Pointer id(触摸用) +# scrcpy 中 SC_POINTER_ID_GENERIC_FINGER = UINT64_C(-2), +# 作为有符号 int64 写入 q 格式即 -2,对应无符号 0xFFFFFFFFFFFFFFFE +_POINTER_ID_FINGER = -2 + class ScrcpyController(AndroidController): """基于 scrcpy 协议的 Android 设备控制器。 - 截图通过 scrcpy-server 提供的 H264 视频流解码获得(30+ fps), - 触控/按键等操作通过 adbutils 的 ``adb shell input`` 实现。 + 截图通过 scrcpy-server 提供的 H264 视频流解码获得(30+ fps); + 触控(click/swipe/long_tap)、按键与文本输入通过 scrcpy 控制流实现, + 相比 ``adb shell input`` 显著降低延迟。仅应用管理类操作仍走 ADB。 Parameters ---------- @@ -91,10 +121,12 @@ def __init__( self._alive = False self._server_stream: AdbConnection | None = None self._video_socket: socket.socket | None = None + self._control_socket: socket.socket | None = None self._decode_thread: threading.Thread | None = None self._frame_ready = threading.Event() # 首帧就绪信号 self._frame_lock = threading.Lock() self._reconnect_lock = threading.Lock() + self._control_lock = threading.Lock() # 控制流写串行化 # ── 连接 ── @@ -131,6 +163,7 @@ def connect(self) -> DeviceInfo: self._deploy_server() self._start_server() self._connect_video_socket() + self._connect_control_socket() self._start_decode_thread() # ── 等待首帧 ── @@ -220,7 +253,7 @@ def _start_server(self) -> None: 'tunnel_forward=true', 'video=true', 'audio=false', - 'control=false', + 'control=true', f'max_size={self._max_size}', f'video_bit_rate={self._bitrate}', f'max_fps={self._max_fps}', @@ -259,6 +292,34 @@ def _connect_video_socket(self) -> None: raise EmulatorConnectionError('未收到 scrcpy dummy byte,连接可能已断开') _log.debug('[Emulator] scrcpy 视频通道已连接') + def _connect_control_socket(self) -> None: + """连接 scrcpy 控制通道(video 之后建立的第二个 socket)。 + + scrcpy 2.7 中 ``send_dummy_byte=true`` 时 dummy byte 仅由第一个 socket + (video)发送,控制 socket 无需读取 dummy byte。 + """ + import adbutils + + dev = self._require_device() + for _attempt in range(30): + try: + self._control_socket = dev.create_connection( + adbutils.Network.LOCAL_ABSTRACT, + 'scrcpy', + ) + break + except Exception: + time.sleep(0.1) + else: + raise EmulatorConnectionError('无法连接 scrcpy-server 控制通道(3s 超时)') + + # 禁用 Nagle,降低控制指令延迟 + try: + self._control_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + _log.debug('[Emulator] scrcpy 控制通道已连接') + def _start_decode_thread(self) -> None: """启动后台 H264 解码线程。""" self._alive = True @@ -331,6 +392,13 @@ def _require_device(self) -> AdbDevice: return self._device def _close_video_channel(self) -> None: + if self._control_socket is not None: + try: + self._control_socket.close() + except Exception as exc: + _log.debug('[Scrcpy] 关闭 control socket 失败: {}', exc) + self._control_socket = None + if self._video_socket is not None: try: self._video_socket.close() @@ -353,12 +421,13 @@ def _close_video_channel(self) -> None: self._frame_ready.clear() def _reopen_stream(self) -> None: - """在保持 ADB 设备连接的前提下重建 scrcpy 视频流。""" + """在保持 ADB 设备连接的前提下重建 scrcpy 视频流与控制流。""" self._close_video_channel() self._last_frame = None self._deploy_server() self._start_server() self._connect_video_socket() + self._connect_control_socket() self._start_decode_thread() if not self._frame_ready.wait(timeout=self._screenshot_timeout): @@ -380,6 +449,116 @@ def _ensure_stream_alive(self) -> None: _log.warning('[Emulator] 检测到视频流未运行,尝试自动重连') self._reopen_stream() + # ── 控制流消息发送 ── + + def _require_control_socket(self) -> socket.socket: + """返回控制 socket;视频流未就绪时先尝试恢复。""" + self._ensure_stream_alive() + if self._control_socket is None: + raise EmulatorConnectionError('scrcpy 控制通道未连接') + return self._control_socket + + def _send_control(self, data: bytes) -> None: + """向控制 socket 串行写入一帧消息(线程安全)。""" + sock = self._require_control_socket() + with self._control_lock: + try: + sock.sendall(data) + except (ConnectionError, OSError) as exc: + _log.warning('[Emulator] 控制流发送失败,触发重连: {}', exc) + self._alive = False + self._ensure_stream_alive() + sock = self._require_control_socket() + sock.sendall(data) + + @staticmethod + def _float_to_u16fp(value: float) -> int: + """将 [0.0, 1.0] 压力值编码为 scrcpy u16 定点数。""" + clamped = max(0.0, min(1.0, value)) + return round(clamped * 0xFFFF) + + def _inject_touch( + self, + action: int, + x: float, + y: float, + pressure: float = 1.0, + pointer_id: int = _POINTER_ID_FINGER, + ) -> None: + """发送 INJECT_TOUCH_EVENT 控制消息(scrcpy 2.7,32 字节)。 + + 布局:type(1) | action(1) | pointer_id(8) | x(4) | y(4) + | width(2) | height(2) | pressure(2) | action_button(4) | buttons(4) + """ + w, h = self._resolution + px, py = int(x * w), int(y * h) + u16_pressure = self._float_to_u16fp(pressure) + data = struct.pack( + '>BBqIIHHHII', + _TYPE_INJECT_TOUCH_EVENT, + action, + pointer_id, + px, + py, + w, + h, + u16_pressure, + 0, # action_button(触摸事件为 0) + 0, # buttons(触摸事件为 0) + ) + # struct '>BBqIIHHHII' = 1+1+8+4+4+2+2+2+4+4 = 32 字节 + self._send_control(data) + + def _inject_keycode(self, key_code: int, action: int = _KEY_ACTION_UP) -> None: + """发送 INJECT_KEYCODE 控制消息(scrcpy 2.7,14 字节)。 + + 布局:type(1) | action(1) | keycode(4) | repeat(4) | metastate(4) + """ + data = struct.pack( + '>BBIII', + _TYPE_INJECT_KEYCODE, + action, + key_code, + 0, # repeat + 0, # metastate + ) + self._send_control(data) + + def _inject_text(self, content: str) -> None: + """发送 INJECT_TEXT 控制消息(scrcpy 2.7)。 + + 布局:type(1) | length(4) | utf8_bytes + 最大长度 300 字节。 + + 注意:INJECT_TEXT 通过 InputConnection.commitText 注入, + 仅对 ASCII/拉丁字符可靠;中文等多字节字符请使用 :meth:`_set_clipboard`。 + """ + raw = content.encode('utf-8')[:300] + data = struct.pack('>BI', _TYPE_INJECT_TEXT, len(raw)) + raw + self._send_control(data) + + def _set_clipboard(self, content: str, paste: bool = True) -> None: + """发送 SET_CLIPBOARD 控制消息(scrcpy 2.7)。 + + 布局:type(1) | sequence(8) | paste(1) | length(4) | utf8_bytes + + ``paste=True`` 时,server 在设置剪贴板后会自动注入 + ``KEYCODE_PASTE``(Android ≥ 7),实现立即粘贴。 + 该路径通过系统剪贴板 + 粘贴键工作,可正确输入中文等 + INJECT_TEXT 无法处理的字符。 + + Parameters + ---------- + content: + 要输入的文本(UTF-8,最长 262130 字节)。 + paste: + 是否在设置剪贴板后立即粘贴。 + """ + raw = content.encode('utf-8')[:_CLIPBOARD_TEXT_MAX_LENGTH] + # sequence=0 表示 SEQUENCE_INVALID,不请求 ack + data = struct.pack('>BQBI', _TYPE_SET_CLIPBOARD, 0, 1 if paste else 0, len(raw)) + raw + self._send_control(data) + # ── 截图 ── def screenshot(self) -> np.ndarray: @@ -415,7 +594,6 @@ def screenshot(self) -> np.ndarray: # ── 触控 ── # 引入一个开关,当参数为:click(x, y, delay=False) 时关闭延迟,该方法默认打开全局延迟,全局延迟可以在 config.py 内设置 def click(self, x: float, y: float, *, delay: bool = True) -> None: - dev = self._require_device() w, h = self._resolution px, py = int(x * w), int(y * h) _log.debug( @@ -428,7 +606,9 @@ def click(self, x: float, y: float, *, delay: bool = True) -> None: h, caller_info(), ) - dev.shell(f'input tap {px} {py}') + self._inject_touch(_ACTION_DOWN, x, y, pressure=1.0) + self._inject_touch(_ACTION_UP, x, y, pressure=0.0) + if delay: # True 才走延迟 time.sleep( random.uniform( @@ -447,7 +627,6 @@ def swipe( *, delay: bool = True, ) -> None: - dev = self._require_device() w, h = self._resolution px1, py1 = int(x1 * w), int(y1 * h) px2, py2 = int(x2 * w), int(y2 * h) @@ -465,7 +644,19 @@ def swipe( ms, caller_info(), ) - dev.shell(f'input swipe {px1} {py1} {px2} {py2} {ms}') + # 按下 + self._inject_touch(_ACTION_DOWN, x1, y1, pressure=1.0) + # 在 duration 内插值若干 MOVE 事件,保证流畅滑动 + steps = max(1, ms // 16) # ~60fps,每步约 16ms + step_ms = ms / steps + for i in range(1, steps + 1): + t = i / steps + cx = x1 + (x2 - x1) * t + cy = y1 + (y2 - y1) * t + self._inject_touch(_ACTION_MOVE, cx, cy, pressure=1.0) + time.sleep(step_ms / 1000.0) + # 抬起 + self._inject_touch(_ACTION_UP, x2, y2, pressure=0.0) # 增加延迟,改动同 click_delay if delay: # True 才走延迟 @@ -477,13 +668,29 @@ def swipe( ) def long_tap(self, x: float, y: float, duration: float = 1.0) -> None: - self.swipe(x, y, x, y, duration=duration) + w, h = self._resolution + px, py = int(x * w), int(y * h) + ms = int(duration * 1000) + _log.debug( + '[Emulator] long_tap({:.3f}, {:.3f}) → pixel({},{}) {}ms {}', + x, + y, + px, + py, + ms, + caller_info(), + ) + # 按下后保持不动,再抬起(与 input swipe 同点等价) + self._inject_touch(_ACTION_DOWN, x, y, pressure=1.0) + time.sleep(duration) + self._inject_touch(_ACTION_UP, x, y, pressure=0.0) # ── 按键 ── def key_event(self, key_code: int, *, delay: bool = True) -> None: - dev = self._require_device() _log.debug('[Emulator] key_event({}) {}', key_code, caller_info()) - dev.keyevent(key_code) + # 发送 DOWN + UP 完成一次按键 + self._inject_keycode(key_code, action=_KEY_ACTION_DOWN) + self._inject_keycode(key_code, action=_KEY_ACTION_UP) # 增加延迟,改动同 click_delay if delay: # True 才走延迟 @@ -495,9 +702,13 @@ def key_event(self, key_code: int, *, delay: bool = True) -> None: ) def text(self, content: str, *, delay: bool = True) -> None: - dev = self._require_device() _log.debug("[Emulator] text('{}') {}", content, caller_info()) - dev.send_keys(content) + if content.isascii(): + # 纯 ASCII/拉丁字符走 INJECT_TEXT(延迟更低) + self._inject_text(content) + else: + # 含中文等多字节字符 → SET_CLIPBOARD + paste(Android ≥ 7) + self._set_clipboard(content, paste=True) # 增加延迟,改动同 click_delay if delay: # True 才走延迟 diff --git a/testing/emulator/test_controller.py b/testing/emulator/test_controller.py index 869d7afd..a3f9dada 100644 --- a/testing/emulator/test_controller.py +++ b/testing/emulator/test_controller.py @@ -3,11 +3,12 @@ 由于 ScrcpyController 依赖物理设备/模拟器,测试策略: 1. DeviceInfo — 不可变数据类 2. ScrcpyController — ABC 接口约束 -3. ScrcpyController — 坐标转换逻辑(mock airtest) +3. ScrcpyController — 控制流消息序列化与坐标转换(mock control socket) """ from __future__ import annotations +import struct import time from unittest.mock import MagicMock @@ -20,6 +21,17 @@ from autowsgr.infra import EmulatorConnectionError +# ── 控制流协议常量(与 scrcpy.py 保持一致)── +_TYPE_INJECT_TOUCH_EVENT = 2 +_TYPE_INJECT_KEYCODE = 0 +_TYPE_INJECT_TEXT = 1 +_TYPE_SET_CLIPBOARD = 9 +_ACTION_DOWN = 0 +_ACTION_UP = 1 +_ACTION_MOVE = 2 +_POINTER_ID_FINGER = -2 + + # ═══════════════════════════════════════════════ # ScrcpyController — 初始化 / 状态 # ═══════════════════════════════════════════════ @@ -38,64 +50,151 @@ def test_disconnect_resets_state(self): # ═══════════════════════════════════════════════ -# ScrcpyController — 坐标转换 +# ScrcpyController — 控制流消息序列化与坐标转换 # ═══════════════════════════════════════════════ -class TestScrcpyControllerCoordinates: - """测试 click/swipe 的相对-绝对坐标转换。""" +class TestScrcpyControllerControlFlow: + """测试 click/swipe/key_event/text 通过控制流发送正确的二进制消息。""" @pytest.fixture def ctrl(self) -> ScrcpyController: - """创建一个 mock 设备的 ScrcpyController。""" + """创建一个 mock 设备 + mock 控制通道的 ScrcpyController。""" c = ScrcpyController(serial='test') c._resolution = (960, 540) c._device = MagicMock() - c._device.shell = MagicMock(return_value='') + c._alive = True + c._control_socket = MagicMock() return c + @staticmethod + def _parse_touch(data: bytes) -> tuple: + """解析 INJECT_TOUCH_EVENT 消息,返回各字段元组。 + + 布局:type(1) | action(1) | pointer_id(8) | x(4) | y(4) + | width(2) | height(2) | pressure(2) | action_button(4) | buttons(4) + + 返回索引:[0]type [1]action [2]pointer_id [3]x [4]y + [5]width [6]height [7]pressure [8]action_button [9]buttons + """ + return struct.unpack('>BBqIIHHHII', data) + def test_click_center(self, ctrl: ScrcpyController): - ctrl.click(0.5, 0.5) - ctrl._device.shell.assert_called_once_with('input tap 480 270') + """click(0.5, 0.5) 在 960x540 上 → DOWN+UP at (480, 270)。""" + sock = ctrl._control_socket + ctrl.click(0.5, 0.5, delay=False) + assert sock.sendall.call_count == 2 + down = self._parse_touch(sock.sendall.call_args_list[0].args[0]) + up = self._parse_touch(sock.sendall.call_args_list[1].args[0]) + assert down[0] == _TYPE_INJECT_TOUCH_EVENT + assert down[1] == _ACTION_DOWN + assert down[3] == 480 and down[4] == 270 + assert up[1] == _ACTION_UP + assert down[5] == 960 and down[6] == 540 # width, height def test_click_top_left(self, ctrl: ScrcpyController): - ctrl.click(0.0, 0.0) - ctrl._device.shell.assert_called_once_with('input tap 0 0') + ctrl.click(0.0, 0.0, delay=False) + down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) + assert down[3] == 0 and down[4] == 0 def test_click_bottom_right(self, ctrl: ScrcpyController): - ctrl.click(1.0, 1.0) - ctrl._device.shell.assert_called_once_with('input tap 960 540') + ctrl.click(1.0, 1.0, delay=False) + down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) + assert down[3] == 960 and down[4] == 540 def test_click_quarter(self, ctrl: ScrcpyController): - ctrl.click(0.25, 0.75) - ctrl._device.shell.assert_called_once_with('input tap 240 405') - - def test_swipe_default_duration(self, ctrl: ScrcpyController): - ctrl.swipe(0.1, 0.2, 0.9, 0.8) - ctrl._device.shell.assert_called_once_with('input swipe 96 108 864 432 500') - - def test_swipe_custom_duration(self, ctrl: ScrcpyController): - ctrl.swipe(0.0, 0.0, 1.0, 1.0, duration=1.0) - ctrl._device.shell.assert_called_once_with('input swipe 0 0 960 540 1000') - - def test_swipe_short_duration(self, ctrl: ScrcpyController): - ctrl.swipe(0.5, 0.5, 0.6, 0.6, duration=0.2) - ctrl._device.shell.assert_called_once_with('input swipe 480 270 576 324 200') - - def test_long_tap_delegates_to_swipe(self, ctrl: ScrcpyController): - """long_tap 通过 swipe(x, y, x, y, duration) 实现。""" - ctrl.long_tap(0.5, 0.5, duration=2.0) - ctrl._device.shell.assert_called_once_with('input swipe 480 270 480 270 2000') + ctrl.click(0.25, 0.75, delay=False) + down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) + assert down[3] == 240 and down[4] == 405 + + def test_swipe_down_move_up(self, ctrl: ScrcpyController): + """swipe 发送 DOWN、中间多个 MOVE、最后 UP。""" + sock = ctrl._control_socket + ctrl.swipe(0.1, 0.2, 0.9, 0.8, duration=0.1, delay=False) + frames = [c.args[0] for c in sock.sendall.call_args_list] + first = self._parse_touch(frames[0]) + last = self._parse_touch(frames[-1]) + assert first[0] == _TYPE_INJECT_TOUCH_EVENT + assert first[1] == _ACTION_DOWN + assert first[3] == 96 and first[4] == 108 + assert last[1] == _ACTION_UP + assert last[3] == 864 and last[4] == 432 + # 中间应有 MOVE + middle_actions = {self._parse_touch(f)[1] for f in frames[1:-1]} + assert _ACTION_MOVE in middle_actions + + def test_long_tap_down_then_up(self, ctrl: ScrcpyController): + """long_tap 发送 DOWN、等待、UP,坐标相同。""" + sock = ctrl._control_socket + ctrl.long_tap(0.5, 0.5, duration=0.05) + assert sock.sendall.call_count == 2 + down = self._parse_touch(sock.sendall.call_args_list[0].args[0]) + up = self._parse_touch(sock.sendall.call_args_list[1].args[0]) + assert down[3] == up[3] == 480 + assert down[4] == up[4] == 270 + + def test_key_event_sends_keycode_down_up(self, ctrl: ScrcpyController): + """key_event 通过 INJECT_KEYCODE 发送 DOWN+UP。""" + sock = ctrl._control_socket + ctrl.key_event(4, delay=False) # BACK + assert sock.sendall.call_count == 2 + # 每条 14 字节:type(1) action(1) keycode(4) repeat(4) meta(4) + d0 = sock.sendall.call_args_list[0].args[0] + d1 = sock.sendall.call_args_list[1].args[0] + assert len(d0) == 14 and len(d1) == 14 + t0, a0, k0, _, _ = struct.unpack('>BBIII', d0) + t1, a1, k1, _, _ = struct.unpack('>BBIII', d1) + assert t0 == _TYPE_INJECT_KEYCODE and t1 == _TYPE_INJECT_KEYCODE + assert k0 == 4 and k1 == 4 + assert a0 == 0 # DOWN + assert a1 == 1 # UP + + def test_text_sends_inject_text(self, ctrl: ScrcpyController): + """text 通过 INJECT_TEXT 发送 UTF-8 文本。""" + sock = ctrl._control_socket + ctrl.text('hello', delay=False) + assert sock.sendall.call_count == 1 + data = sock.sendall.call_args_list[0].args[0] + msg_type = data[0] + length = struct.unpack('>I', data[1:5])[0] + payload = data[5:] + assert msg_type == _TYPE_INJECT_TEXT + assert length == 5 + assert payload == b'hello' + + def test_text_chinese_uses_set_clipboard(self, ctrl: ScrcpyController): + """中文文本走 SET_CLIPBOARD+paste 路径。""" + sock = ctrl._control_socket + ctrl.text('你好', delay=False) + assert sock.sendall.call_count == 1 + data = sock.sendall.call_args_list[0].args[0] + # type(1) | sequence(8) | paste(1) | length(4) | utf8 + assert data[0] == _TYPE_SET_CLIPBOARD + assert data[9] == 1 # paste=True + length = struct.unpack('>I', data[10:14])[0] + assert length == 6 # 你好 = 6 bytes UTF-8 + assert data[14:] == '你好'.encode('utf-8') def test_high_resolution(self): - """1920x1080 分辨率下的转换。""" + """1920x1080 分辨率下坐标转换正确。""" c = ScrcpyController(serial='test') c._resolution = (1920, 1080) c._device = MagicMock() - c._device.shell = MagicMock(return_value='') - - c.click(0.5, 0.5) - c._device.shell.assert_called_once_with('input tap 960 540') + c._alive = True + c._control_socket = MagicMock() + c.click(0.5, 0.5, delay=False) + down = TestScrcpyControllerControlFlow._parse_touch( + c._control_socket.sendall.call_args_list[0].args[0] + ) + assert down[3] == 960 and down[4] == 540 + + def test_control_socket_none_raises(self, ctrl: ScrcpyController): + """控制通道未连接时调用触控应抛异常。""" + ctrl._control_socket = None + ctrl._alive = False + ctrl._ensure_stream_alive = MagicMock(side_effect=EmulatorConnectionError('mock')) + with pytest.raises(EmulatorConnectionError): + ctrl._send_control(b'\x00') # ═══════════════════════════════════════════════ From 31067a7d8e3ba4791207006909c0d7289de1609f Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:37:17 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(config):=20classic=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=BC=E5=AE=B9=E5=B1=82=E9=87=8D=E6=9E=84=E4=B8=BA?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=B4=A9=E6=BA=83+=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=B7=A5=E5=85=B7,=20=E6=96=B0=E5=A2=9E=20operation=5Fdelay=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_compat 拆为 detect_* (运行期只读, from_yaml 检测到老版本即抛 LegacyConfigError 提示迁移) 与 migrate_* (纯转换, 供工具调用), 移除 运行期静默迁移与回写原文件逻辑 - UserConfig 新增 operation_delay_min/max 字段 + 校验器写回模块全局, 修复 classic delay 迁移后值丢失 bug (scrcpy 通过 operation_delay() 读取) - 新增 tools/migrate_config.py 独立迁移工具: 指定 --usersettings/--planroot, 默认 cwd 查找, 输出到新目录 (原文件不动), 支持 --dry-run/--force - plan.from_yaml 检测到 classic 前导空 fleet 同样崩溃提示迁移 Co-Authored-By: Claude --- autowsgr/combat/plan.py | 14 ++ autowsgr/infra/config.py | 151 +++++++++++-- autowsgr/infra/config_compat.py | 279 ++++++++++++++++++++++++ autowsgr/infra/file_utils.py | 62 ++++-- testing/infra/test_config.py | 315 ++++++++++++++++++++++++++- testing/tools/__init__.py | 0 testing/tools/test_migrate_config.py | 137 ++++++++++++ tools/migrate_config.py | 260 ++++++++++++++++++++++ 8 files changed, 1170 insertions(+), 48 deletions(-) create mode 100644 autowsgr/infra/config_compat.py create mode 100644 testing/tools/__init__.py create mode 100644 testing/tools/test_migrate_config.py create mode 100644 tools/migrate_config.py diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 9ddf86a2..3adf5313 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -271,7 +271,21 @@ def is_selected_node(self, node: str) -> bool: @classmethod def from_yaml(cls, path: str | Path) -> CombatPlan: + from autowsgr.infra.config_compat import ( + LegacyConfigError, + detect_legacy_plan, + ) + data = load_yaml(path) + legacy = detect_legacy_plan(data) + if legacy: + raise LegacyConfigError( + f'计划文件 {path} 含 classic 老版本格式, 拒绝加载:\n - ' + + '\n - '.join(legacy) + + '\n请先运行迁移工具迁移计划目录 (原文件不动):\n' + ' python tools/migrate_config.py --planroot <计划目录>' + ' (默认输出到 migrated_config/)', + ) return cls.from_dict(data, name=Path(path).stem) @classmethod diff --git a/autowsgr/infra/config.py b/autowsgr/infra/config.py index 1b6decae..b53835e3 100644 --- a/autowsgr/infra/config.py +++ b/autowsgr/infra/config.py @@ -7,6 +7,7 @@ import datetime import os +import random from pathlib import Path from typing import Any, Literal @@ -36,6 +37,21 @@ OPERATION_DELAY_MIN: float = 0.0 OPERATION_DELAY_MAX: float = 0.0 + +def operation_delay() -> float: + """本次 UI 操作后的随机延迟秒数。 + + 受 :data:`OPERATION_DELAY_MIN` / :data:`OPERATION_DELAY_MAX` 控制。 + 兼容层把 classic 的 ``delay`` 单值映射为 ``MIN = MAX = delay``; + 本函数读取模块全局, 故运行期修改 :data:`OPERATION_DELAY_MIN/MAX` 即时生效 + (``scrcpy.py`` 通过调用本函数而非 ``from import`` 绑定值)。 + """ + return random.uniform( + min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), + max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), + ) + + # ── 子配置模型 ── @@ -60,11 +76,7 @@ class AccountConfig(BaseModel): model_config = {'frozen': True} game_app: GameAPP = GameAPP.official - """游戏渠道""" - account: str | None = None - """游戏账号""" - password: str | None = None - """游戏密码""" + """游戏渠道(决定 Android 包名)""" @property def package_name(self) -> str: @@ -97,15 +109,10 @@ class LogConfig(BaseModel): dir: Path | None = None """日志保存路径。自动按日期生成""" - # 细粒度显示开关 - show_map_node: bool = False - show_android_input: bool = True - show_enemy_rules: bool = True - show_fight_stage: bool = True - show_chapter_info: bool = True - show_match_fight_stage: bool = True + # 细粒度显示开关 (仅保留有效开关; classic 遗留的 show_map_node / + # show_android_input / show_enemy_rules / show_fight_stage / + # show_chapter_info / show_match_fight_stage / show_ocr_info 已移除) show_decisive_battle_info: bool = True - show_ocr_info: bool = True show_emulator_debug: bool = True """是否输出 emulator 通道的 DEBUG 日志(click/swipe 等操作)。""" show_ui_debug: bool = True @@ -165,6 +172,29 @@ def effective_channels(self) -> dict[str, str]: return merged +class NormalFightTaskConfig(BaseModel): + """单个常规战任务配置。 + + 兼容 classic 的 ``[plan_name, fleet_id, target_times]`` 列表写法 + (经 :func:`_parse_normal_fight_tasks` 解析), 也支持纯字符串 (仅 plan 名) + 或完整字典。 + """ + + model_config = {'frozen': True} + + name: str + """常规战计划名 (如 ``8-5AI-only1DD``), 解析为 ``autowsgr/data/plan/normal_fight/`` 下的文件。""" + fleet_id: int | None = None + """出征舰队编号; 留空则用 plan 内配置。""" + times: int | None = None + """目标出击次数; ``None`` 表示无限 (空闲填充, 仅受全局上限约束)。 + + .. note:: ``times=None`` (无限) 且未开启 ``stop_max_ship`` / + ``stop_max_loot`` / ``quick_repair_limit`` 任一上限时, 常规战会持续 + 产出任务、永远抢占浴室修理 (优先级更低), 致浴室修理永不执行。 + """ + + class DailyAutomationConfig(BaseModel): """日常自动化设置。""" @@ -179,6 +209,8 @@ class DailyAutomationConfig(BaseModel): """空闲时自动澡堂修理""" auto_set_support: bool = False """自动开启战役支援""" + bath_repair_blacklist: list[str] = Field(default_factory=list) + """浴室修理黑名单:这些舰船名(中文全名)不会被自动浴室修理。""" # 战役 auto_battle: bool = True @@ -206,8 +238,8 @@ class DailyAutomationConfig(BaseModel): # 常规战 auto_normal_fight: bool = True """按自定义任务进行常规战""" - normal_fight_tasks: list[str] = Field(default_factory=list) - """常规战任务列表""" + normal_fight_tasks: list[NormalFightTaskConfig] = Field(default_factory=list) + """常规战任务列表; 兼容 classic ``[name, fleet, times]`` 写法 (见下方校验器)。""" quick_repair_limit: int | None = None """快修消耗上限""" stop_max_ship: bool = False @@ -215,6 +247,42 @@ class DailyAutomationConfig(BaseModel): stop_max_loot: bool = False """获取当天上限 50 胖次后终止""" + @field_validator('normal_fight_tasks', mode='before') + @classmethod + def _parse_normal_fight_tasks(cls, v: object) -> list[dict[str, Any]]: + """把 classic ``[name, fleet, times]`` 列表 / 纯字符串 / dict 统一成 dict。 + + classic 的 user_settings.yaml 里写法是:: + + normal_fight_tasks: + - [8-5AI-only1DD, 4, 900] # [plan, fleet_id, target_times] + + Pydantic 默认无法把嵌套 list 塞进结构化模型, 这里在校验前转换。 + """ + if v is None: + return [] + if not isinstance(v, list): + raise TypeError(f'normal_fight_tasks 应为列表, 得到 {type(v).__name__}') + result: list[dict[str, Any]] = [] + for item in v: + if isinstance(item, str): + result.append({'name': item}) + elif isinstance(item, dict): + result.append(item) + elif isinstance(item, (list, tuple)): + # [name, fleet_id, target_times] + if len(item) < 1: + raise ValueError(f'常规战任务条目不能为空: {item!r}') + entry: dict[str, Any] = {'name': str(item[0])} + if len(item) >= 2 and item[1] is not None: + entry['fleet_id'] = int(item[1]) # type: ignore[arg-type] + if len(item) >= 3 and item[2] is not None: + entry['times'] = int(item[2]) # type: ignore[arg-type] + result.append(entry) + else: + raise TypeError(f'无法识别的常规战任务条目: {item!r}') + return result + class DecisiveConfig(BaseModel): """决战自动化配置。""" @@ -275,18 +343,22 @@ class UserConfig(BaseModel): """操作系统类型,自动检测""" # 脚本行为 - delay: float = 1.5 - """延迟时间基本单位 (秒)""" - check_page: bool = True - """启动时是否检查游戏页面""" + # classic 的 delay 已移除: 运行期检测到会崩溃提示迁移 (见 config_compat); + # 新版用 operation_delay_min/max 字段, 由 _apply_operation_delay 写回模块 + # 全局 OPERATION_DELAY_MIN/MAX (供 operation_delay() 读取)。 + # check_page 功能已由 launcher.ensure_ready 覆盖。 + operation_delay_min: float = 0.0 + """UI 操作后随机延迟下界 (秒)。兼容层把 classic 的 delay 同时迁为本字段与 _max。""" + operation_delay_max: float = 0.0 + """UI 操作后随机延迟上界 (秒)。""" dock_full_destroy: bool = True """船坞满时自动清空""" repair_manually: bool = False """是否手动修理""" bathroom_feature_count: int = 1 - """浴室装饰数 (1-3)""" + """浴室装饰数 (1-3)。预留:智能浴场空位调度用。""" bathroom_count: int = 2 - """修理位置总数 (≤12)""" + """修理位置总数 (≤12)。预留:智能浴场空位调度用。""" # 解装设置 destroy_ship_work_mode: DestroyShipWorkMode = DestroyShipWorkMode.disable @@ -320,8 +392,7 @@ def _coerce_destroy_mode(cls, v: object) -> object: # 数据路径 plan_root: Path | None = None """自定义计划文件目录""" - ship_name_file: Path | None = None - """自定义舰船名文件""" + # ship_name_file 已移除 (新版无需自定义舰船名文件; 兼容层会提示用户删除) @model_validator(mode='after') def _resolve_emulator_defaults(self) -> UserConfig: @@ -357,10 +428,42 @@ def _resolve_emulator_defaults(self) -> UserConfig: return self + @model_validator(mode='after') + def _apply_operation_delay(self) -> UserConfig: + """把 operation_delay_min/max 写回模块全局, 供 operation_delay() 读取。 + + classic 的 ``delay`` 由迁移工具迁为本字段; 这样延迟值随配置持久化, + 不会再因「迁移回写删字段」而丢失。运行期修改这两个全局即时生效 + (``scrcpy.py`` 通过调用 :func:`operation_delay` 而非 ``from import`` 绑定值)。 + """ + global OPERATION_DELAY_MIN, OPERATION_DELAY_MAX # noqa: PLW0603 + OPERATION_DELAY_MIN = self.operation_delay_min + OPERATION_DELAY_MAX = self.operation_delay_max + return self + @classmethod def from_yaml(cls, path: str | Path) -> UserConfig: - """从 YAML 文件加载配置。""" + """从 YAML 文件加载配置。 + + raw dict 先经 :func:`detect_legacy_user_config` 检测 classic 老版本字段; + 命中则抛 :class:`LegacyConfigError` 提示用户运行迁移工具 + (``tools/migrate_config.py``), **不再自动迁移 / 回写**。干净则交 Pydantic 校验。 + """ + from autowsgr.infra.config_compat import ( + LegacyConfigError, + detect_legacy_user_config, + ) + data = load_yaml(path) + legacy = detect_legacy_user_config(data) + if legacy: + raise LegacyConfigError( + f'配置文件 {path} 含 classic 老版本字段, 拒绝加载:\n - ' + + '\n - '.join(legacy) + + '\n请先运行迁移工具生成新配置 (原文件不动):\n' + f' python tools/migrate_config.py --usersettings "{path}"\n' + '(可加 --planroot <计划目录> 一起迁移; 默认输出到 migrated_config/)', + ) return cls.model_validate(data) diff --git a/autowsgr/infra/config_compat.py b/autowsgr/infra/config_compat.py new file mode 100644 index 00000000..563084b7 --- /dev/null +++ b/autowsgr/infra/config_compat.py @@ -0,0 +1,279 @@ +"""向下兼容层 — 检测 classic 老版本配置项 (运行期崩溃) + 迁移 (独立工具)。 + +两类**纯 API**, 均无副作用 (不写文件、不改运行期全局): + +- :func:`detect_legacy_user_config` / :func:`detect_legacy_plan`: + 运行期 ``from_yaml`` 调用。只读, 返回命中的老版本项描述列表; 非空则 + ``from_yaml`` 抛 :class:`LegacyConfigError`, 提示用户运行迁移工具升级。 +- :func:`migrate_raw_config` / :func:`migrate_plan_dict`: + 迁移工具 (``tools/migrate_config.py``) 调用。原地把 raw dict 转成 dev + 格式, 由工具负责把结果写到新输出目录 (原文件不动)。 + +检测 / 迁移的老版本项 (运行期一律**不再自动处理**, 必须先迁移): + +- ``ship_name_file`` (新版无需自定义舰船名文件) — 删除 +- ``account.account`` / ``account.password`` (自动登录已移除) — 删除 +- ``delay`` (classic 单值秒数) → ``operation_delay_min`` / ``operation_delay_max`` +- ``emulator_type`` / ``emulator_start_cmd`` / ``emulator_name`` (顶层平铺) + → 嵌套 ``emulator`` 块 (``type`` / ``path`` / ``serial``) +- ``check_update`` / ``show_map_node`` (顶层废弃) — 删除 +- 计划文件 ``fleet`` 前导空占位 (classic 1-indexed) → dev 0-indexed +""" + +from __future__ import annotations + +from typing import Any + +from autowsgr.infra.logger import get_logger + + +_log = get_logger('infra') + + +class LegacyConfigError(Exception): + """配置含 classic 老版本字段, 需先运行迁移工具 (``tools/migrate_config.py``)。""" + + +# ── 常量 / 辅助 (detect 与 migrate 共享, 单一事实源) ── + +# classic 平铺模拟器字段 (顶层) → dev 嵌套 emulator 块键名 +_LEGACY_EMULATOR_FIELDS: dict[str, str] = { + 'emulator_type': 'type', + 'emulator_start_cmd': 'path', + 'emulator_name': 'serial', +} + +# classic 顶层废弃字段 (dev 不使用, 子模型 extra='ignore' 会静默丢弃) +_LEGACY_TOPLEVEL_DROPPED: tuple[str, ...] = ( + 'check_update', + 'show_map_node', +) + + +def _is_empty_fleet_slot(value: object) -> bool: + """是否是 fleet 的"空槽位" (``None`` / 空串 / 纯空白)。 + + 与 :func:`autowsgr.ui.battle.fleet_change._normalize_ship_name` 的 + 空判定一致: 这些值在运行期都会被归一化为 ``None`` (该槽位留空)。 + """ + if value is None: + return True + return isinstance(value, str) and not value.strip() + + +# ── 检测 (运行期, 只读) ── + + +def detect_legacy_user_config(data: Any) -> list[str]: + """返回 *data* 中命中的老版本用户配置项描述; 空列表 = 干净。 + + 运行期 :meth:`UserConfig.from_yaml ` + 调用: 非空即抛 :class:`LegacyConfigError`。纯只读, 不修改 *data*。 + """ + if not isinstance(data, dict): + return [] + found: list[str] = [] + if 'ship_name_file' in data: + found.append('ship_name_file (新版无需自定义舰船名文件)') + account = data.get('account') + if isinstance(account, dict) and any(k in account for k in ('account', 'password')): + found.append('account.account / account.password (自动登录已移除)') + if 'delay' in data: + found.append('delay (请改用 operation_delay_min / operation_delay_max)') + if any(k in data for k in _LEGACY_EMULATOR_FIELDS): + found.append( + 'classic 平铺模拟器字段 emulator_type / emulator_start_cmd / ' + 'emulator_name (请改用嵌套 emulator 块)', + ) + if any(k in data for k in _LEGACY_TOPLEVEL_DROPPED): + found.append( + '、'.join(k for k in _LEGACY_TOPLEVEL_DROPPED if k in data) + ' (顶层废弃)', + ) + return found + + +def detect_legacy_plan(data: Any) -> list[str]: + """返回 *data* 中命中的老版本计划项描述; 空列表 = 干净。 + + 运行期 :meth:`CombatPlan.from_yaml ` + 调用: 非空即抛 :class:`LegacyConfigError`。纯只读, 不修改 *data*。 + """ + if not isinstance(data, dict): + return [] + fleet = data.get('fleet') + if isinstance(fleet, list) and fleet and _is_empty_fleet_slot(fleet[0]): + return ['classic 1-indexed fleet 前导空占位 (首位为空, 请剥离)'] + return [] + + +# ── 迁移 (工具, 原地转换) ── + + +def migrate_raw_config(data: Any) -> Any: + """原地转换 raw 用户配置 dict 为 dev 格式并返回同一对象。 + + 迁移工具调用; 与 :func:`detect_legacy_user_config` 对应。无副作用: + 不写文件、不改运行期全局 (``operation_delay`` 全局由 + :class:`~autowsgr.infra.config.UserConfig` 字段 + 校验器接管)。 + """ + if not isinstance(data, dict): + return data + _migrate_ship_name_file(data) + _migrate_account(data) + _migrate_delay(data) + _migrate_emulator_legacy(data) + _migrate_misc_legacy(data) + return data + + +def migrate_plan_dict(data: Any) -> Any: + """原地转换 raw 计划 dict 为 dev 格式并返回同一对象。 + + 目前处理 classic 的 1-indexed ``fleet`` 前导空占位 + (见 :func:`_migrate_plan_fleet`)。迁移工具调用, 无副作用。 + """ + if not isinstance(data, dict): + return data + _migrate_plan_fleet(data) + return data + + +# ── 迁移子函数 (原地改 data) ── + + +def _migrate_ship_name_file(data: dict[str, Any]) -> None: + """ship_name_file: 新版无需自定义舰船名文件, 删除并提示。""" + if 'ship_name_file' in data: + _log.warning('[compat] ship_name_file 已废弃 (新版无需), 已从配置移除。') + del data['ship_name_file'] + + +def _migrate_account(data: dict[str, Any]) -> None: + """account.account / password: 自动登录已移除, 删除并提示 (保留 game_app)。""" + account = data.get('account') + if not isinstance(account, dict): + return + removed = [k for k in ('account', 'password') if k in account] + if not removed: + return + _log.warning( + '[compat] 自动登录已移除, account 块中的 {} 已删除 (保留 game_app)。', + '/'.join(removed), + ) + for key in removed: + del account[key] + + +def _migrate_delay(data: dict[str, Any]) -> None: + """delay: classic 单值秒数 → operation_delay_min = operation_delay_max = delay。 + + 纯字段赋值 (由 :class:`~autowsgr.infra.config.UserConfig` 校验器 + ``_apply_operation_delay`` 把字段值写回运行期全局); 不在此设全局。 + """ + if 'delay' not in data: + return + raw = data.pop('delay') + try: + delay = float(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + _log.warning( + '[compat] delay={!r} 无法解析为数值, 已忽略; ' + '如需 UI 操作延迟请设置 operation_delay_min/max。', + raw, + ) + return + data['operation_delay_min'] = delay + data['operation_delay_max'] = delay + _log.info('[compat] delay={} 已迁移为 operation_delay_min/max。', delay) + + +def _migrate_emulator_legacy(data: dict[str, Any]) -> None: + """classic 平铺模拟器字段 → 嵌套 ``emulator`` 块。 + + classic 把模拟器配置平铺在顶层 (``emulator_type`` / + ``emulator_start_cmd`` / ``emulator_name``); dev 改为嵌套 + ``emulator:`` 块 (``type`` / ``path`` / ``serial``)。本函数把平铺 + 字段搬进嵌套块, 让老配置直接生效 (否则顶层字段被 ``extra='ignore'`` + 静默丢弃, 模拟器会回退到默认雷电)。 + + 值原样透传, 由 :class:`~autowsgr.infra.config.EmulatorConfig` 校验 / + 解析 (如 ``"MuMu"`` → ``EmulatorType.mumu``)。``None`` 值不写, 让 + dev 自动检测。若已含嵌套 ``emulator`` 块, 以嵌套为准, 平铺仅补缺。 + """ + found = {legacy: data[legacy] for legacy in _LEGACY_EMULATOR_FIELDS if legacy in data} + if not found: + return + + emu = data.get('emulator') + if emu is None: + emu = {} + data['emulator'] = emu + if not isinstance(emu, dict): + _log.warning( + '[compat] 检测到 classic 平铺模拟器字段 {} 但 emulator 块非 dict, ' + '无法自动迁移, 平铺字段已删除。', + '/'.join(found), + ) + for legacy in found: + del data[legacy] + return + + migrated: list[str] = [] + for legacy, new_key in _LEGACY_EMULATOR_FIELDS.items(): + if legacy not in found: + continue + del data[legacy] + value = found[legacy] + if value is None or new_key in emu: + continue # 空值让 dev 自动检测; 嵌套已有则以嵌套为准 + emu[new_key] = value + migrated.append(f'{legacy}→emulator.{new_key}') + + _log.warning( + '[compat] classic 平铺模拟器字段已迁移到嵌套 emulator 块 ({}){}。', + ', '.join(migrated) if migrated else '仅清理空值', + '' if migrated else ', 未写入新值', + ) + + +def _migrate_misc_legacy(data: dict[str, Any]) -> None: + """classic 顶层已废弃字段 (check_update / 顶层 show_map_node 等) 删除并提示。""" + dropped = [k for k in _LEGACY_TOPLEVEL_DROPPED if k in data] + if not dropped: + return + for key in dropped: + del data[key] + _log.warning('[compat] classic 顶层字段 {} 已废弃 (新版不使用), 已删除。', '/'.join(dropped)) + + +def _migrate_plan_fleet(data: dict[str, Any]) -> None: + r"""classic 1-indexed ``fleet`` → dev 0-indexed: 剥离前导空占位元素。 + + classic 计划的 ``fleet`` 是 1-indexed, 首位恒为 ``""`` 占位, 例如:: + + fleet: ["", "吹雪", "明斯克", "胡德", "赤诚", ""] + + dev 的 :meth:`~autowsgr.ui.battle.fleet_change.FleetChangeMixin.change_fleet` + 按 0-indexed 取前 6 个, 前导空占位会把舰船整体右移一位、丢掉第 6 船, + 并触发 ``_reorder`` 的 ``break`` 致验证反复重试 ("卡很多次 fleet 验证")。 + 本函数剥离所有前导"空槽位", 让经典写法直接生效。 + + 中间 / 尾部的 ``""`` 原样保留 —— 运行期 ``_normalize_ship_name`` 会把 + 它们归一化为 ``None`` (= 不关心该槽位), 无需在此处理。 + """ + fleet = data.get('fleet') + if not isinstance(fleet, list) or not fleet: + return + + cleaned = list(fleet) + while cleaned and _is_empty_fleet_slot(cleaned[0]): + cleaned.pop(0) + + if cleaned != fleet: + data['fleet'] = cleaned + _log.warning( + '[compat] classic 1-indexed fleet 前导空占位已剥离 ({} → {}), ' + '已迁移为 dev 0-indexed 格式。', + fleet, + cleaned, + ) diff --git a/autowsgr/infra/file_utils.py b/autowsgr/infra/file_utils.py index 1919454a..69c67dd1 100644 --- a/autowsgr/infra/file_utils.py +++ b/autowsgr/infra/file_utils.py @@ -20,27 +20,42 @@ def _get_package_data_dir() -> Path: return Path(spec.origin).resolve().parent / 'data' +def _plan_candidates(directory: Path, name: str) -> list[Path]: + """返回 *directory* 下策略文件的候选路径 (裸名 + 补全 ``.yaml``)。""" + base = directory / name + if base.suffix: + return [base] + return [base, base.with_suffix('.yaml')] + + def resolve_plan_path( name_or_path: str | Path, category: str = 'normal_fight', + plan_root: str | Path | None = None, ) -> Path: """解析策略文件路径。 - 查找优先级: + 查找优先级 (先命中先返回): - 1. *name_or_path* 直接作为路径(绝对路径或相对于 cwd),若存在即使用。 - 2. 同上,补全 ``.yaml`` 后缀再试。 - 3. 在 ``autowsgr/data/plan/{category}/`` 包数据目录中查找。 - 4. 同上,补全 ``.yaml`` 后缀再试。 + 1. *name_or_path* 直接作为路径 (绝对路径或相对于 cwd), 若存在即使用。 + 2. 同上, 补全 ``.yaml`` 后缀再试。 + 3. 若指定 *plan_root*: 在 ``{plan_root}/{category}/`` 中查找 —— 用户自定义, + 优先于包内默认, 用于覆盖或新增策略 (对齐 classic ``plan_root`` 语义)。 + 4. 同上, 补全 ``.yaml`` 后缀再试。 + 5. 在 ``autowsgr/data/plan/{category}/`` 包数据目录中查找。 + 6. 同上, 补全 ``.yaml`` 后缀再试。 支持 pip 安装模式和开发模式。 Parameters ---------- name_or_path: - 策略文件名(如 ``"7-4千伪"``)或完整路径。 + 策略文件名 (如 ``"7-4千伪"``) 或完整路径。 category: - 策略分类子目录,如 ``"normal_fight"``、``"event"``。 + 策略分类子目录, 如 ``"normal_fight"``、``"event"``。 + plan_root: + 用户自定义策略根目录。其下应按 ``{category}/{name}.yaml`` 组织, + 与包内 ``data/plan/`` 同构; 同名文件优先于包内默认, 未命中则回退。 Returns ------- @@ -53,30 +68,37 @@ def resolve_plan_path( 所有候选路径均不存在。 """ p = Path(name_or_path) + searched: list[Path] = [] # 1. 直接路径 if p.exists(): return p.resolve() + searched.append(p) # 2. 补全 .yaml if not p.suffix: p_yaml = p.with_suffix('.yaml') if p_yaml.exists(): return p_yaml.resolve() + searched.append(p_yaml) - # 3. 包数据目录 - data_dir = _get_package_data_dir() / 'plan' / category - candidate = data_dir / p.name - if candidate.exists(): - return candidate.resolve() + # 3-4. 用户自定义 plan_root (优先于包内默认) + if plan_root is not None: + for cand in _plan_candidates(Path(plan_root) / category, p.name): + if cand.exists(): + return cand.resolve() + searched.append(cand) - # 4. 包数据目录 + .yaml - if not candidate.suffix: - candidate_yaml = candidate.with_suffix('.yaml') - if candidate_yaml.exists(): - return candidate_yaml.resolve() + # 5-6. 包数据目录 + data_dir = _get_package_data_dir() / 'plan' / category + for cand in _plan_candidates(data_dir, p.name): + if cand.exists(): + return cand.resolve() + searched.append(cand) - raise FileNotFoundError(f'策略文件未找到: {name_or_path!r}\n已搜索: {p} | {data_dir / p.name}') + raise FileNotFoundError( + f'策略文件未找到: {name_or_path!r}\n已搜索: {" | ".join(str(s) for s in searched)}' + ) def load_yaml(path: str | Path) -> dict[str, Any]: @@ -117,7 +139,9 @@ def save_yaml(data: dict[str, Any], path: str | Path) -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with open(path, 'w', encoding='utf-8') as f: - yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + # sort_keys=False: 保留 dict 插入顺序 (= 用户原始键序), + # 避免写回用户配置时把键重排成字母序造成无谓 diff。 + yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) def merge_dicts(base: dict, override: dict) -> dict: diff --git a/testing/infra/test_config.py b/testing/infra/test_config.py index 2c5ee902..f07391e2 100644 --- a/testing/infra/test_config.py +++ b/testing/infra/test_config.py @@ -27,6 +27,18 @@ def _mock_wsl(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(OSType, '_is_wsl', staticmethod(lambda: True)) +@pytest.fixture(autouse=True) +def _reset_operation_delay(): + """每个用例前后复位 OPERATION_DELAY 全局, 避免 delay 迁移污染其他用例。""" + from autowsgr.infra import config + + config.OPERATION_DELAY_MIN = 0.0 + config.OPERATION_DELAY_MAX = 0.0 + yield + config.OPERATION_DELAY_MIN = 0.0 + config.OPERATION_DELAY_MAX = 0.0 + + # ── EmulatorConfig ── @@ -58,15 +70,18 @@ def test_from_yaml(self, tmp_yaml: Callable[[str, str], Path]): path: "C:/fake/player.exe" account: game_app: "官服" -delay: 2.0 +operation_delay_min: 2.0 +operation_delay_max: 3.0 dock_full_destroy: false """ path = tmp_yaml('config.yaml', content) cfg = UserConfig.from_yaml(path) assert cfg.emulator.type == EmulatorType.bluestacks assert cfg.emulator.serial == '127.0.0.1:5555' - assert cfg.delay == 2.0 assert cfg.dock_full_destroy is False + assert cfg.operation_delay_min == 2.0 + assert cfg.operation_delay_max == 3.0 + assert not hasattr(cfg, 'delay') def test_with_daily_automation(self, tmp_yaml: Callable[[str, str], Path]): content = """\ @@ -152,17 +167,307 @@ def test_load_existing_file(self, tmp_yaml: Callable[[str, str], Path]): type: "MuMu" serial: "127.0.0.1:16384" path: "C:/fake/MuMuPlayer.exe" -delay: 2.5 +operation_delay_min: 2.5 """ path = tmp_yaml('settings.yaml', content) cfg = ConfigManager.load(path) assert cfg.emulator.type == EmulatorType.mumu - assert cfg.delay == 2.5 + assert cfg.operation_delay_min == 2.5 + assert not hasattr(cfg, 'delay') def test_load_nonexistent_returns_default(self, tmp_path: Path): cfg = ConfigManager.load(tmp_path / 'no_such_file.yaml') assert isinstance(cfg, UserConfig) - assert cfg.delay == 1.5 + assert not hasattr(cfg, 'delay') + + +# ── ConfigCompat (向下兼容迁移) ── + + +class TestConfigCompat: + """老版本配置: detect (运行期崩溃) + migrate (工具, 纯转换) + operation_delay 字段。""" + + # ── detect_legacy_user_config (只读) ── + + def test_detect_clean_returns_empty(self): + from autowsgr.infra.config_compat import detect_legacy_user_config + + assert detect_legacy_user_config({'emulator': {'type': 'MuMu'}}) == [] + + def test_detect_hits_each_legacy_item(self): + from autowsgr.infra.config_compat import detect_legacy_user_config + + assert detect_legacy_user_config({'ship_name_file': 'x'}) + assert detect_legacy_user_config({'account': {'account': 'a', 'password': 'b'}}) + assert detect_legacy_user_config({'delay': 1.5}) + assert detect_legacy_user_config({'emulator_type': 'MuMu'}) + assert detect_legacy_user_config({'check_update': False}) + + def test_detect_non_dict_returns_empty(self): + from autowsgr.infra.config_compat import detect_legacy_user_config + + assert detect_legacy_user_config(None) == [] # type: ignore[arg-type] + + # ── migrate_raw_config (原地转换, 无副作用) ── + + def test_ship_name_file_removed(self): + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'ship_name_file': '/tmp/x.json'}) + assert 'ship_name_file' not in out + + def test_account_credentials_removed(self): + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config( + {'account': {'game_app': '官服', 'account': 'a', 'password': 'b'}}, + ) + assert out['account'] == {'game_app': '官服'} + + def test_delay_migrated_to_operation_delay_fields(self): + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'delay': 1.5}) + assert 'delay' not in out + assert out['operation_delay_min'] == 1.5 + assert out['operation_delay_max'] == 1.5 + + def test_delay_unparseable_dropped(self): + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'delay': 'abc'}) + assert 'delay' not in out + assert 'operation_delay_min' not in out + + def test_operation_delay_reads_global(self): + from autowsgr.infra import config + + config.OPERATION_DELAY_MIN = 2.0 + config.OPERATION_DELAY_MAX = 2.0 + assert config.operation_delay() == 2.0 + + def test_non_dict_passthrough(self): + from autowsgr.infra.config_compat import migrate_raw_config + + assert migrate_raw_config(None) is None # type: ignore[arg-type] + + def test_legacy_emulator_fields_migrated(self): + """classic 平铺 emulator_type/start_cmd/name → 嵌套 emulator 块, 值透传。""" + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config( + { + 'emulator_type': 'MuMu', + 'emulator_start_cmd': 'C:/fake/MuMuPlayer.exe', + 'emulator_name': '127.0.0.1:16384', + }, + ) + assert out['emulator'] == { + 'type': 'MuMu', + 'path': 'C:/fake/MuMuPlayer.exe', + 'serial': '127.0.0.1:16384', + } + assert 'emulator_type' not in out + + def test_nested_emulator_takes_precedence(self): + """同时存在平铺与嵌套时, 以嵌套 emulator 块为准。""" + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config( + { + 'emulator_type': 'MuMu', + 'emulator': {'type': '蓝叠', 'serial': '127.0.0.1:5555'}, + }, + ) + assert out['emulator'] == {'type': '蓝叠', 'serial': '127.0.0.1:5555'} + assert 'emulator_type' not in out + + def test_legacy_null_emulator_fields_skipped(self): + """None 值的平铺模拟器字段不写入嵌套 (让 dev 自动检测)。""" + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config( + {'emulator_type': 'MuMu', 'emulator_start_cmd': None, 'emulator_name': None}, + ) + assert out['emulator'] == {'type': 'MuMu'} + + def test_legacy_toplevel_fields_dropped(self): + """check_update / 顶层 show_map_node 等 classic 废弃字段被清理。""" + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'check_update': False, 'show_map_node': True}) + assert 'check_update' not in out + assert 'show_map_node' not in out + + # ── detect 与 migrate 一致性 (防漂移) ── + + def test_detect_migrate_consistency(self): + import copy + + from autowsgr.infra.config_compat import ( + detect_legacy_user_config, + migrate_raw_config, + ) + + samples = [ + {}, + {'emulator': {'type': 'MuMu'}}, + {'ship_name_file': 'x'}, + {'delay': 1.0}, + {'emulator_type': 'MuMu'}, + {'check_update': True}, + {'account': {'account': 'a'}}, + ] + for sample in samples: + detected = bool(detect_legacy_user_config(sample)) + before = copy.deepcopy(sample) + migrate_raw_config(sample) + changed = sample != before + assert detected == changed, f'detect/migrate 不一致: {sample!r}' + + # ── operation_delay_min/max 字段 → 模块全局 ── + + def test_operation_delay_field_sets_globals(self): + from autowsgr.infra import config + + cfg = UserConfig(operation_delay_min=1.0, operation_delay_max=2.0) + assert cfg.operation_delay_min == 1.0 + assert cfg.operation_delay_max == 2.0 + assert config.OPERATION_DELAY_MIN == 1.0 + assert config.OPERATION_DELAY_MAX == 2.0 + + # ── 运行期 from_yaml: 检测到老版本即崩溃 ── + + def test_from_yaml_crashes_on_legacy_emulator( + self, + tmp_yaml: Callable[[str, str], Path], + ): + from autowsgr.infra.config_compat import LegacyConfigError + + path = tmp_yaml('emu_legacy.yaml', "emulator_type: 'MuMu'\n") + with pytest.raises(LegacyConfigError, match='迁移工具'): + UserConfig.from_yaml(path) + + def test_from_yaml_crashes_on_delay(self, tmp_yaml: Callable[[str, str], Path]): + from autowsgr.infra.config_compat import LegacyConfigError + + path = tmp_yaml('delay_legacy.yaml', 'delay: 1.5\n') + with pytest.raises(LegacyConfigError, match='delay'): + UserConfig.from_yaml(path) + + def test_clean_config_loads_and_not_rewritten( + self, + tmp_yaml: Callable[[str, str], Path], + ): + """干净配置正常加载; from_yaml 不再有写回副作用, 文件字节原样保留。""" + content = """\ +emulator: + type: "MuMu" + path: "C:/fake/MuMuPlayer.exe" + serial: "127.0.0.1:16384" +dock_full_destroy: false +""" + path = tmp_yaml('clean.yaml', content) + before = path.read_text(encoding='utf-8') + cfg = UserConfig.from_yaml(path) + assert cfg.emulator.type == EmulatorType.mumu + assert path.read_text(encoding='utf-8') == before + + +# ── PlanCompat (计划文件迁移: classic fleet 前导空占位) ── + + +class TestPlanCompat: + """计划文件老版本: detect_legacy_plan (崩溃) + migrate_plan_dict (剥离前导空)。""" + + # ── detect_legacy_plan (只读) ── + + def test_detect_clean_fleet_empty(self): + from autowsgr.infra.config_compat import detect_legacy_plan + + assert detect_legacy_plan({'fleet': ['吹雪', '明斯克']}) == [] + assert detect_legacy_plan({}) == [] + + def test_detect_leading_empty_fleet(self): + from autowsgr.infra.config_compat import detect_legacy_plan + + assert detect_legacy_plan({'fleet': ['', '吹雪']}) + assert detect_legacy_plan({'fleet': [None, '吹雪']}) + + # ── migrate_plan_dict (原地转换) ── + + def test_leading_empty_string_stripped(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + out = migrate_plan_dict({'fleet': ['', '吹雪', '明斯克', '胡德', '赤诚', '']}) + # 前导 "" 占位被剥离; 尾部 "" 保留 (运行期归一化为 None = 不关心该槽位) + assert out['fleet'] == ['吹雪', '明斯克', '胡德', '赤诚', ''] + + def test_leading_none_stripped(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + out = migrate_plan_dict({'fleet': [None, '吹雪', '明斯克']}) + assert out['fleet'] == ['吹雪', '明斯克'] + + def test_multiple_leading_empties_stripped(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + out = migrate_plan_dict({'fleet': ['', '', '吹雪']}) + assert out['fleet'] == ['吹雪'] + + def test_whitespace_only_treated_as_empty(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + out = migrate_plan_dict({'fleet': [' ', '吹雪']}) + assert out['fleet'] == ['吹雪'] + + def test_clean_fleet_unchanged(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + clean = ['飞龙', 'U-1206', 'U-47', '射水鱼', 'U-96', '鲃鱼'] + out = migrate_plan_dict({'fleet': list(clean)}) + assert out['fleet'] == clean + + def test_non_list_fleet_skipped(self): + """fleet 缺省 / None / 非 list 都不动。""" + from autowsgr.infra.config_compat import migrate_plan_dict + + assert migrate_plan_dict({}) == {} + assert migrate_plan_dict({'fleet': None}) == {'fleet': None} + assert migrate_plan_dict({'fleet': '吹雪'}) == {'fleet': '吹雪'} + + def test_empty_list_fleet_skipped(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + assert migrate_plan_dict({'fleet': []}) == {'fleet': []} + + def test_non_dict_passthrough(self): + from autowsgr.infra.config_compat import migrate_plan_dict + + assert migrate_plan_dict(None) is None # type: ignore[arg-type] + + def test_from_yaml_crashes_on_legacy_fleet(self, tmp_yaml: Callable[[str, str], Path]): + """CombatPlan.from_yaml 检测到 classic 前导空 fleet → 崩溃提示迁移。""" + from autowsgr.combat.plan import CombatPlan + from autowsgr.infra.config_compat import LegacyConfigError + + path = tmp_yaml('plan_legacy.yaml', 'fleet: ["", "吹雪", "明斯克"]\n') + with pytest.raises(LegacyConfigError, match='迁移工具'): + CombatPlan.from_yaml(path) + + def test_clean_plan_loads_and_not_rewritten( + self, + tmp_yaml: Callable[[str, str], Path], + ): + """干净 fleet 的计划文件正常加载, 不被改写 (格式保留)。""" + from autowsgr.combat.plan import CombatPlan + + content = 'fleet: ["飞龙", "U-1206"]\n' + path = tmp_yaml('plan_clean.yaml', content) + before = path.read_text(encoding='utf-8') + plan = CombatPlan.from_yaml(path) + assert plan.fleet == ['飞龙', 'U-1206'] + assert path.read_text(encoding='utf-8') == before # ── LogConfig (setup_logger) ── diff --git a/testing/tools/__init__.py b/testing/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/tools/test_migrate_config.py b/testing/tools/test_migrate_config.py new file mode 100644 index 00000000..213c0c5c --- /dev/null +++ b/testing/tools/test_migrate_config.py @@ -0,0 +1,137 @@ +"""tools/migrate_config.py 端到端测试 (无网络/无设备)。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from autowsgr.infra.file_utils import load_yaml + +from tools import migrate_config + + +if TYPE_CHECKING: + from pathlib import Path + + +def test_migrate_usersettings_delay_to_operation_delay(tmp_path: Path): + """classic delay → operation_delay_min/max, 原文件不动。""" + src = tmp_path / 'usersettings.yaml' + src.write_text('emulator: {}\ndelay: 1.5\n', encoding='utf-8') + out = tmp_path / 'out' + + rc = migrate_config.main(['--usersettings', str(src), '--output', str(out)]) + + assert rc == 0 + assert (out / 'usersettings.yaml').exists() + # 原文件不动 + assert 'delay: 1.5' in src.read_text(encoding='utf-8') + # 迁移后: delay 消失, operation_delay_min/max 各等于 delay + migrated = load_yaml(out / 'usersettings.yaml') + assert 'delay' not in migrated + assert migrated['operation_delay_min'] == 1.5 + assert migrated['operation_delay_max'] == 1.5 + + +def test_migrate_usersettings_emulator_flat_to_nested(tmp_path: Path): + """classic 平铺模拟器字段 → 嵌套 emulator 块。""" + src = tmp_path / 'usersettings.yaml' + src.write_text( + 'emulator_type: "MuMu"\nemulator_start_cmd: "C:/x.exe"\nemulator_name: "127.0.0.1:16384"\n', + encoding='utf-8', + ) + out = tmp_path / 'out' + + rc = migrate_config.main(['--usersettings', str(src), '--output', str(out)]) + + assert rc == 0 + migrated = load_yaml(out / 'usersettings.yaml') + assert migrated['emulator'] == { + 'type': 'MuMu', + 'path': 'C:/x.exe', + 'serial': '127.0.0.1:16384', + } + assert 'emulator_type' not in migrated + + +def test_migrate_planroot_mirrors_structure_and_strips_fleet(tmp_path: Path): + """planroot 递归镜像到 plans/, classic 前导空 fleet 被剥离。""" + planroot = tmp_path / 'plans' + (planroot / 'normal_fight').mkdir(parents=True) + (planroot / 'event').mkdir(parents=True) + (planroot / 'normal_fight' / 'a.yaml').write_text( + 'fleet: ["", "吹雪", "明斯克"]\n', + encoding='utf-8', + ) + (planroot / 'event' / 'b.yaml').write_text( + 'fleet: ["飞龙", "U-1206"]\n', + encoding='utf-8', + ) + out = tmp_path / 'out' + + rc = migrate_config.main(['--planroot', str(planroot), '--output', str(out)]) + + assert rc == 0 + dst_a = out / 'plans' / 'normal_fight' / 'a.yaml' + dst_b = out / 'plans' / 'event' / 'b.yaml' + assert dst_a.exists() + assert dst_b.exists() + assert load_yaml(dst_a)['fleet'] == ['吹雪', '明斯克'] # 前导空剥离 + assert load_yaml(dst_b)['fleet'] == ['飞龙', 'U-1206'] # 干净不变 + + +def test_clean_file_copied_as_is_preserves_comments(tmp_path: Path): + """干净计划文件原样复制 (保留注释, 不经 save_yaml 重排)。""" + planroot = tmp_path / 'plans' + planroot.mkdir() + plan = planroot / 'c.yaml' + body = '# 注释\nfleet: ["飞龙"]\n' + plan.write_text(body, encoding='utf-8') + out = tmp_path / 'out' + + rc = migrate_config.main(['--planroot', str(planroot), '--output', str(out)]) + + assert rc == 0 + assert (out / 'plans' / 'c.yaml').read_text(encoding='utf-8') == body + + +def test_dry_run_writes_nothing(tmp_path: Path): + src = tmp_path / 'usersettings.yaml' + src.write_text('delay: 1.0\n', encoding='utf-8') + out = tmp_path / 'out' + + rc = migrate_config.main( + ['--usersettings', str(src), '--output', str(out), '--dry-run'], + ) + + assert rc == 0 + assert not out.exists() + + +def test_existing_nonempty_output_without_force_errors(tmp_path: Path): + src = tmp_path / 'usersettings.yaml' + src.write_text('delay: 1.0\n', encoding='utf-8') + out = tmp_path / 'out' + out.mkdir() + (out / 'stale.txt').write_text('x', encoding='utf-8') + + rc = migrate_config.main(['--usersettings', str(src), '--output', str(out)]) + + assert rc == 1 + # 原输出目录里的占位文件还在 (未被覆盖) + assert (out / 'stale.txt').exists() + + +def test_force_overwrites_existing_output(tmp_path: Path): + src = tmp_path / 'usersettings.yaml' + src.write_text('delay: 1.0\n', encoding='utf-8') + out = tmp_path / 'out' + out.mkdir() + (out / 'stale.txt').write_text('x', encoding='utf-8') + + rc = migrate_config.main( + ['--usersettings', str(src), '--output', str(out), '--force'], + ) + + assert rc == 0 + assert not (out / 'stale.txt').exists() # 旧目录被清空重建 + assert (out / 'usersettings.yaml').exists() diff --git a/tools/migrate_config.py b/tools/migrate_config.py new file mode 100644 index 00000000..21e73d8d --- /dev/null +++ b/tools/migrate_config.py @@ -0,0 +1,260 @@ +"""classic 老版本配置迁移工具 — 生成新版配置目录 (原文件不动)。 + +把 classic 写法的 usersettings / 计划文件迁移为 dev 格式, 输出到一个**新目录**, +不修改原文件。框架运行期 (``UserConfig`` / ``CombatPlan`` 的 ``from_yaml``) 检测到 +老版本字段会直接崩溃并提示运行本工具。 + +迁移内容 (见 :mod:`autowsgr.infra.config_compat`): + +- ``ship_name_file`` / ``account.account`` / ``account.password`` → 删除 +- ``delay`` → ``operation_delay_min`` / ``operation_delay_max`` +- ``emulator_type`` / ``emulator_start_cmd`` / ``emulator_name`` → 嵌套 ``emulator`` 块 +- ``check_update`` / ``show_map_node`` → 删除 +- 计划文件 ``fleet`` 前导空占位 → 剥离 (1-indexed → 0-indexed) + +用法 +---- +:: + + # 迁移当前目录下的 usersettings.yaml 和 plans/ (默认输出 migrated_config/) + python tools/migrate_config.py + + # 只迁移指定 usersettings + python tools/migrate_config.py --usersettings path/to/usersettings.yaml + + # 只迁移指定计划目录 + python tools/migrate_config.py --planroot path/to/plans + + # 两者一起, 指定输出目录 + python tools/migrate_config.py --usersettings a.yaml --planroot plans --output out + +可选参数 +-------- + --usersettings PATH 用户配置 YAML (默认: 当前目录 usersettings.yaml) + --planroot PATH 计划文件根目录 (默认: 当前目录 plans/) + --output PATH 输出目录 (默认: migrated_config) + --dry-run 只打印将迁移什么, 不写文件 + --force 输出目录已存在且非空时强制覆盖 +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +# ── 项目根路径 ─────────────────────────────────────────────────────────────── +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT)) + +# ── UTF-8 输出兼容 (Windows 终端) ──────────────────────────────────────────── +try: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] + sys.stderr.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] +except Exception: # noqa: S110 + pass + +from autowsgr.infra.config_compat import ( # noqa: E402 + detect_legacy_plan, + detect_legacy_user_config, + migrate_plan_dict, + migrate_raw_config, +) +from autowsgr.infra.file_utils import load_yaml, save_yaml # noqa: E402 + + +if TYPE_CHECKING: + from collections.abc import Callable + + +_DEFAULT_USERSETTINGS = 'usersettings.yaml' +_DEFAULT_PLANROOT = 'plans' +_DEFAULT_OUTPUT = 'migrated_config' + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description='把 classic 老版本配置迁移为 dev 格式, 输出到新目录 (原文件不动)。', + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + '--usersettings', + default=None, + metavar='PATH', + help=f'用户配置 YAML (默认: 当前目录 {_DEFAULT_USERSETTINGS})', + ) + p.add_argument( + '--planroot', + default=None, + metavar='PATH', + help=f'计划文件根目录 (默认: 当前目录 {_DEFAULT_PLANROOT}/)', + ) + p.add_argument( + '--output', + default=_DEFAULT_OUTPUT, + metavar='PATH', + help=f'输出目录 (默认: {_DEFAULT_OUTPUT})', + ) + p.add_argument('--dry-run', action='store_true', help='只打印, 不写文件') + p.add_argument( + '--force', + action='store_true', + help='输出目录已存在且非空时强制覆盖', + ) + return p.parse_args(argv) + + +def _resolve_inputs( + args: argparse.Namespace, +) -> tuple[Path | None, Path | None]: + """解析 usersettings / planroot 路径。 + + 显式指定时无条件采用 (后续在 main 里校验存在性); 未指定则取 cwd 默认, + 仅当默认路径真实存在时才纳入, 否则为 None (跳过)。 + """ + if args.usersettings: + usersettings: Path | None = Path(args.usersettings) + else: + default_us = Path.cwd() / _DEFAULT_USERSETTINGS + usersettings = default_us if default_us.exists() else None + + if args.planroot: + planroot: Path | None = Path(args.planroot) + else: + default_pr = Path.cwd() / _DEFAULT_PLANROOT + planroot = default_pr if default_pr.is_dir() else None + + return usersettings, planroot + + +def _prepare_output(output: Path, force: bool, dry_run: bool) -> bool: + """检查 / 创建输出目录。返回是否可继续 (False = 报错中止)。""" + if dry_run: + return True + if output.exists() and any(output.iterdir()): + if not force: + return False + shutil.rmtree(output) + output.mkdir(parents=True, exist_ok=True) + return True + + +def _process( + src: Path, + dst: Path, + detect: Callable[[Any], list[str]], + migrate: Callable[[Any], Any], + *, + dry_run: bool, +) -> list[str]: + """处理单个 YAML: 返回命中的老版本项; 非 dry-run 时写出。 + + 命中老版本 → ``migrate`` + :func:`save_yaml` (会丢注释, 仅影响需迁移的文件); + 干净 → 原样复制 (保留注释)。 + """ + data = load_yaml(src) + legacy = detect(data) + if dry_run: + return legacy + dst.parent.mkdir(parents=True, exist_ok=True) + if legacy: + migrate(data) + save_yaml(data, dst) + else: + shutil.copy2(src, dst) + return legacy + + +def main(argv: list[str] | None = None) -> int: # noqa: PLR0912 + args = _parse_args(argv) + usersettings, planroot = _resolve_inputs(args) + + print('=' * 60) + print(' classic 配置迁移工具') + print('=' * 60) + print(f' usersettings: {usersettings or "(未找到, 跳过)"}') + print(f' planroot: {planroot or "(未找到, 跳过)"}') + print(f' 输出目录: {args.output}') + print(f' dry-run: {args.dry_run}') + print() + + if usersettings is None and planroot is None: + print(' [ERROR] 未找到任何可迁移的配置 (usersettings.yaml / plans/)。') + print(' 请用 --usersettings / --planroot 显式指定路径。') + return 1 + + output = Path(args.output) + if not _prepare_output(output, args.force, args.dry_run): + print( + f' [ERROR] 输出目录 {output} 已存在且非空。' + '请用 --output 指定其他路径, 或加 --force 覆盖。', + ) + return 1 + + total = 0 + migrated = 0 + + # ── usersettings ── + if usersettings is not None: + if not usersettings.exists(): + print(f' [WARN] usersettings 不存在: {usersettings}, 跳过。') + else: + dst = output / _DEFAULT_USERSETTINGS + legacy = _process( + usersettings, + dst, + detect_legacy_user_config, + migrate_raw_config, + dry_run=args.dry_run, + ) + total += 1 + if legacy: + migrated += 1 + print(f' [迁移] {usersettings} → {dst}') + for item in legacy: + print(f' - {item}') + else: + print(f' [跳过] {usersettings} (无需迁移, 原样复制)') + + # ── planroot ── + if planroot is not None: + plan_files = sorted(planroot.rglob('*.yaml')) + if not plan_files: + print(f' [WARN] planroot 下无 .yaml 文件: {planroot}, 跳过。') + else: + print(f' 计划目录共 {len(plan_files)} 个 yaml 文件:') + for src in plan_files: + rel = src.relative_to(planroot) + dst = output / _DEFAULT_PLANROOT / rel + legacy = _process( + src, + dst, + detect_legacy_plan, + migrate_plan_dict, + dry_run=args.dry_run, + ) + total += 1 + if legacy: + migrated += 1 + print(f' [迁移] {src} → {dst}') + for item in legacy: + print(f' - {item}') + # 干净的计划文件静默原样复制 (数量多, 不逐个打印) + + print() + print(f' 处理 {total} 个文件, 其中 {migrated} 个做了迁移。') + if args.dry_run: + print(' [DRY-RUN] 未写入文件。') + else: + print(f' 新配置目录: {output.resolve()}') + print(' 请将运行指向该目录; 若 usersettings 引用了 plan_root, 请相应更新。') + print('=' * 60) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From d63f6aafa4a2e2f4a1240c031945a47cc3239dea Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:37:27 +0800 Subject: [PATCH 03/14] =?UTF-8?q?refactor(emulator):=20scrcpy=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20operation=5Fdelay()=20=E5=87=BD=E6=95=B0=20+=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 操作延迟从直接 import 全局变量改为调用 operation_delay() 函数, 随 config 字段动态生效 (不再绑定导入时的值)。精简 test_controller 测试。 Co-Authored-By: Claude --- autowsgr/emulator/controller/scrcpy.py | 63 ++----- testing/emulator/test_controller.py | 245 +++++++++++-------------- 2 files changed, 124 insertions(+), 184 deletions(-) diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index f856fc16..71a905a6 100644 --- a/autowsgr/emulator/controller/scrcpy.py +++ b/autowsgr/emulator/controller/scrcpy.py @@ -18,7 +18,6 @@ from __future__ import annotations -import random import socket import struct import threading @@ -27,7 +26,7 @@ from typing import TYPE_CHECKING from autowsgr.infra import EmulatorConfig, EmulatorConnectionError -from autowsgr.infra.config import OPERATION_DELAY_MAX, OPERATION_DELAY_MIN +from autowsgr.infra.config import operation_delay from autowsgr.infra.logger import caller_info, get_logger from ..detector import _find_adb, detect_emulators, prompt_user_select, resolve_serial @@ -470,6 +469,7 @@ def _send_control(self, data: bytes) -> None: self._ensure_stream_alive() sock = self._require_control_socket() sock.sendall(data) + time.sleep(0.05) # 避免连续发送过快导致游戏处理不及 @staticmethod def _float_to_u16fp(value: float) -> int: @@ -477,6 +477,11 @@ def _float_to_u16fp(value: float) -> int: clamped = max(0.0, min(1.0, value)) return round(clamped * 0xFFFF) + def _to_absolute(self, x: float, y: float) -> tuple[int, int]: + """将相对坐标 [0.0, 1.0] 转换为设备像素坐标。""" + w, h = self._resolution + return int(x * w), int(y * h) + def _inject_touch( self, action: int, @@ -491,7 +496,7 @@ def _inject_touch( | width(2) | height(2) | pressure(2) | action_button(4) | buttons(4) """ w, h = self._resolution - px, py = int(x * w), int(y * h) + px, py = self._to_absolute(x, y) u16_pressure = self._float_to_u16fp(pressure) data = struct.pack( '>BBqIIHHHII', @@ -595,7 +600,7 @@ def screenshot(self) -> np.ndarray: # 引入一个开关,当参数为:click(x, y, delay=False) 时关闭延迟,该方法默认打开全局延迟,全局延迟可以在 config.py 内设置 def click(self, x: float, y: float, *, delay: bool = True) -> None: w, h = self._resolution - px, py = int(x * w), int(y * h) + px, py = self._to_absolute(x, y) _log.debug( '[Emulator] click({:.3f}, {:.3f}) → pixel({}, {}) res={}x{} {}', x, @@ -610,12 +615,7 @@ def click(self, x: float, y: float, *, delay: bool = True) -> None: self._inject_touch(_ACTION_UP, x, y, pressure=0.0) if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) def swipe( self, @@ -627,9 +627,8 @@ def swipe( *, delay: bool = True, ) -> None: - w, h = self._resolution - px1, py1 = int(x1 * w), int(y1 * h) - px2, py2 = int(x2 * w), int(y2 * h) + px1, py1 = self._to_absolute(x1, y1) + px2, py2 = self._to_absolute(x2, y2) ms = int(duration * 1000) _log.debug( '[Emulator] swipe({:.3f},{:.3f}→{:.3f},{:.3f}) → pixel({},{}→{},{}) {}ms {}', @@ -660,16 +659,10 @@ def swipe( # 增加延迟,改动同 click_delay if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) def long_tap(self, x: float, y: float, duration: float = 1.0) -> None: - w, h = self._resolution - px, py = int(x * w), int(y * h) + px, py = self._to_absolute(x, y) ms = int(duration * 1000) _log.debug( '[Emulator] long_tap({:.3f}, {:.3f}) → pixel({},{}) {}ms {}', @@ -694,12 +687,7 @@ def key_event(self, key_code: int, *, delay: bool = True) -> None: # 增加延迟,改动同 click_delay if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) def text(self, content: str, *, delay: bool = True) -> None: _log.debug("[Emulator] text('{}') {}", content, caller_info()) @@ -712,12 +700,7 @@ def text(self, content: str, *, delay: bool = True) -> None: # 增加延迟,改动同 click_delay if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) # ── 应用管理 ── @@ -728,12 +711,7 @@ def start_app(self, package: str, *, delay: bool = True) -> None: # 增加延迟,改动同 click_delay if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) def stop_app(self, package: str, *, delay: bool = True) -> None: dev = self._require_device() @@ -742,12 +720,7 @@ def stop_app(self, package: str, *, delay: bool = True) -> None: # 增加延迟,改动同 click_delay if delay: # True 才走延迟 - time.sleep( - random.uniform( - min(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - max(OPERATION_DELAY_MIN, OPERATION_DELAY_MAX), - ) - ) + time.sleep(operation_delay()) def is_app_running(self, package: str) -> bool: try: diff --git a/testing/emulator/test_controller.py b/testing/emulator/test_controller.py index a3f9dada..caae9f88 100644 --- a/testing/emulator/test_controller.py +++ b/testing/emulator/test_controller.py @@ -3,35 +3,28 @@ 由于 ScrcpyController 依赖物理设备/模拟器,测试策略: 1. DeviceInfo — 不可变数据类 2. ScrcpyController — ABC 接口约束 -3. ScrcpyController — 控制流消息序列化与坐标转换(mock control socket) +3. ScrcpyController — 坐标转换(纯函数 _to_pixels,独立验证) +4. ScrcpyController — 控制流编排(在 _inject_* 边界断言动作序列与路由) + +控制流的二进制线缆格式(struct 布局、TYPE_* 常量)属于 scrcpy 外部协议,在 +scrcpy.py 中定义。这里不重复声明该格式来校验实现自身——那样只能保证“打包与解包 +用了同一份格式”,无法发现“对真实 scrcpy 协议的误解”。改为只验证控制器自身的逻辑: +相对坐标→像素映射、动作序列(DOWN/UP/MOVE)、文本路由。动作码取自 Android 官方 +定义(MotionEvent / KeyEvent),以字面量形式独立断言,不导入实现侧常量。 """ from __future__ import annotations -import struct import time -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import numpy as np import pytest -from autowsgr.emulator import ( - ScrcpyController, -) +from autowsgr.emulator import ScrcpyController from autowsgr.infra import EmulatorConnectionError -# ── 控制流协议常量(与 scrcpy.py 保持一致)── -_TYPE_INJECT_TOUCH_EVENT = 2 -_TYPE_INJECT_KEYCODE = 0 -_TYPE_INJECT_TEXT = 1 -_TYPE_SET_CLIPBOARD = 9 -_ACTION_DOWN = 0 -_ACTION_UP = 1 -_ACTION_MOVE = 2 -_POINTER_ID_FINGER = -2 - - # ═══════════════════════════════════════════════ # ScrcpyController — 初始化 / 状态 # ═══════════════════════════════════════════════ @@ -50,146 +43,120 @@ def test_disconnect_resets_state(self): # ═══════════════════════════════════════════════ -# ScrcpyController — 控制流消息序列化与坐标转换 +# ScrcpyController — 坐标转换 # ═══════════════════════════════════════════════ -class TestScrcpyControllerControlFlow: - """测试 click/swipe/key_event/text 通过控制流发送正确的二进制消息。""" +class TestScrcpyControllerCoordinates: + """测试相对坐标 [0, 1] → 像素坐标的转换(纯函数 _to_pixels)。 + + 预期像素值由 int(x * w) / int(y * h) 手工计算,不依赖任何线缆格式。 + """ @pytest.fixture def ctrl(self) -> ScrcpyController: - """创建一个 mock 设备 + mock 控制通道的 ScrcpyController。""" c = ScrcpyController(serial='test') c._resolution = (960, 540) - c._device = MagicMock() - c._alive = True - c._control_socket = MagicMock() return c - @staticmethod - def _parse_touch(data: bytes) -> tuple: - """解析 INJECT_TOUCH_EVENT 消息,返回各字段元组。 + def test_center(self, ctrl: ScrcpyController): + assert ctrl._to_absolute(0.5, 0.5) == (480, 270) - 布局:type(1) | action(1) | pointer_id(8) | x(4) | y(4) - | width(2) | height(2) | pressure(2) | action_button(4) | buttons(4) + def test_top_left(self, ctrl: ScrcpyController): + assert ctrl._to_absolute(0.0, 0.0) == (0, 0) - 返回索引:[0]type [1]action [2]pointer_id [3]x [4]y - [5]width [6]height [7]pressure [8]action_button [9]buttons - """ - return struct.unpack('>BBqIIHHHII', data) + def test_bottom_right(self, ctrl: ScrcpyController): + assert ctrl._to_absolute(1.0, 1.0) == (960, 540) - def test_click_center(self, ctrl: ScrcpyController): - """click(0.5, 0.5) 在 960x540 上 → DOWN+UP at (480, 270)。""" - sock = ctrl._control_socket - ctrl.click(0.5, 0.5, delay=False) - assert sock.sendall.call_count == 2 - down = self._parse_touch(sock.sendall.call_args_list[0].args[0]) - up = self._parse_touch(sock.sendall.call_args_list[1].args[0]) - assert down[0] == _TYPE_INJECT_TOUCH_EVENT - assert down[1] == _ACTION_DOWN - assert down[3] == 480 and down[4] == 270 - assert up[1] == _ACTION_UP - assert down[5] == 960 and down[6] == 540 # width, height - - def test_click_top_left(self, ctrl: ScrcpyController): - ctrl.click(0.0, 0.0, delay=False) - down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) - assert down[3] == 0 and down[4] == 0 - - def test_click_bottom_right(self, ctrl: ScrcpyController): - ctrl.click(1.0, 1.0, delay=False) - down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) - assert down[3] == 960 and down[4] == 540 - - def test_click_quarter(self, ctrl: ScrcpyController): - ctrl.click(0.25, 0.75, delay=False) - down = self._parse_touch(ctrl._control_socket.sendall.call_args_list[0].args[0]) - assert down[3] == 240 and down[4] == 405 - - def test_swipe_down_move_up(self, ctrl: ScrcpyController): - """swipe 发送 DOWN、中间多个 MOVE、最后 UP。""" - sock = ctrl._control_socket - ctrl.swipe(0.1, 0.2, 0.9, 0.8, duration=0.1, delay=False) - frames = [c.args[0] for c in sock.sendall.call_args_list] - first = self._parse_touch(frames[0]) - last = self._parse_touch(frames[-1]) - assert first[0] == _TYPE_INJECT_TOUCH_EVENT - assert first[1] == _ACTION_DOWN - assert first[3] == 96 and first[4] == 108 - assert last[1] == _ACTION_UP - assert last[3] == 864 and last[4] == 432 - # 中间应有 MOVE - middle_actions = {self._parse_touch(f)[1] for f in frames[1:-1]} - assert _ACTION_MOVE in middle_actions - - def test_long_tap_down_then_up(self, ctrl: ScrcpyController): - """long_tap 发送 DOWN、等待、UP,坐标相同。""" - sock = ctrl._control_socket - ctrl.long_tap(0.5, 0.5, duration=0.05) - assert sock.sendall.call_count == 2 - down = self._parse_touch(sock.sendall.call_args_list[0].args[0]) - up = self._parse_touch(sock.sendall.call_args_list[1].args[0]) - assert down[3] == up[3] == 480 - assert down[4] == up[4] == 270 - - def test_key_event_sends_keycode_down_up(self, ctrl: ScrcpyController): - """key_event 通过 INJECT_KEYCODE 发送 DOWN+UP。""" - sock = ctrl._control_socket - ctrl.key_event(4, delay=False) # BACK - assert sock.sendall.call_count == 2 - # 每条 14 字节:type(1) action(1) keycode(4) repeat(4) meta(4) - d0 = sock.sendall.call_args_list[0].args[0] - d1 = sock.sendall.call_args_list[1].args[0] - assert len(d0) == 14 and len(d1) == 14 - t0, a0, k0, _, _ = struct.unpack('>BBIII', d0) - t1, a1, k1, _, _ = struct.unpack('>BBIII', d1) - assert t0 == _TYPE_INJECT_KEYCODE and t1 == _TYPE_INJECT_KEYCODE - assert k0 == 4 and k1 == 4 - assert a0 == 0 # DOWN - assert a1 == 1 # UP - - def test_text_sends_inject_text(self, ctrl: ScrcpyController): - """text 通过 INJECT_TEXT 发送 UTF-8 文本。""" - sock = ctrl._control_socket - ctrl.text('hello', delay=False) - assert sock.sendall.call_count == 1 - data = sock.sendall.call_args_list[0].args[0] - msg_type = data[0] - length = struct.unpack('>I', data[1:5])[0] - payload = data[5:] - assert msg_type == _TYPE_INJECT_TEXT - assert length == 5 - assert payload == b'hello' - - def test_text_chinese_uses_set_clipboard(self, ctrl: ScrcpyController): - """中文文本走 SET_CLIPBOARD+paste 路径。""" - sock = ctrl._control_socket - ctrl.text('你好', delay=False) - assert sock.sendall.call_count == 1 - data = sock.sendall.call_args_list[0].args[0] - # type(1) | sequence(8) | paste(1) | length(4) | utf8 - assert data[0] == _TYPE_SET_CLIPBOARD - assert data[9] == 1 # paste=True - length = struct.unpack('>I', data[10:14])[0] - assert length == 6 # 你好 = 6 bytes UTF-8 - assert data[14:] == '你好'.encode('utf-8') + def test_quarter(self, ctrl: ScrcpyController): + assert ctrl._to_absolute(0.25, 0.75) == (240, 405) def test_high_resolution(self): - """1920x1080 分辨率下坐标转换正确。""" + """1920x1080 分辨率下转换正确。""" c = ScrcpyController(serial='test') c._resolution = (1920, 1080) + assert c._to_absolute(0.5, 0.5) == (960, 540) + + +# ═══════════════════════════════════════════════ +# ScrcpyController — 控制流编排 +# ═══════════════════════════════════════════════ + + +class TestScrcpyControllerControlFlow: + """测试 click/swipe/long_tap/key_event/text 的控制流编排。 + + 在 _inject_touch / _inject_keycode / _inject_text / _set_clipboard 边界上 + 断言控制器发出了语义正确的动作序列、坐标与文本路由,而不关心底层字节如何打包。 + + 动作码取自 Android 官方定义(不导入实现侧常量,从而能捕获实现侧常量写错的 bug): + - MotionEvent: ACTION_DOWN=0, ACTION_UP=1, ACTION_MOVE=2 + - KeyEvent: ACTION_DOWN=0, ACTION_UP=1 + """ + + @pytest.fixture + def ctrl(self) -> ScrcpyController: + c = ScrcpyController(serial='test') + c._resolution = (960, 540) c._device = MagicMock() - c._alive = True - c._control_socket = MagicMock() - c.click(0.5, 0.5, delay=False) - down = TestScrcpyControllerControlFlow._parse_touch( - c._control_socket.sendall.call_args_list[0].args[0] - ) - assert down[3] == 960 and down[4] == 540 - - def test_control_socket_none_raises(self, ctrl: ScrcpyController): - """控制通道未连接时调用触控应抛异常。""" + return c + + def test_click_down_then_up(self, ctrl: ScrcpyController): + """click = 同一点先 DOWN(pressure=1) 后 UP(pressure=0)。""" + ctrl._inject_touch = MagicMock() + ctrl.click(0.5, 0.5, delay=False) + assert ctrl._inject_touch.call_args_list == [ + call(0, 0.5, 0.5, pressure=1.0), # ACTION_DOWN + call(1, 0.5, 0.5, pressure=0.0), # ACTION_UP + ] + + def test_swipe_down_moves_up(self, ctrl: ScrcpyController): + """swipe = DOWN → 若干 MOVE → UP,首末坐标落在两端点。""" + ctrl._inject_touch = MagicMock() + ctrl.swipe(0.1, 0.2, 0.9, 0.8, duration=0.1, delay=False) + calls = ctrl._inject_touch.call_args_list + actions = [c.args[0] for c in calls] + assert actions[0] == 0 # DOWN + assert actions[-1] == 1 # UP + assert 2 in actions[1:-1] # 中间至少一个 MOVE + assert calls[0].args[1:3] == (0.1, 0.2) # 起点 + assert calls[-1].args[1:3] == (0.9, 0.8) # 终点 + + def test_long_tap_down_then_up_same_point(self, ctrl: ScrcpyController): + """long_tap = 同一点 DOWN → 保持 → UP。""" + ctrl._inject_touch = MagicMock() + ctrl.long_tap(0.5, 0.5, duration=0.01) + calls = ctrl._inject_touch.call_args_list + assert len(calls) == 2 + assert calls[0].args[1:3] == calls[1].args[1:3] == (0.5, 0.5) + + def test_key_event_down_then_up(self, ctrl: ScrcpyController): + """key_event = 同一 keycode 先 DOWN 后 UP。""" + ctrl._inject_keycode = MagicMock() + ctrl.key_event(4, delay=False) # KEYCODE_BACK + assert ctrl._inject_keycode.call_args_list == [ + call(4, action=0), # ACTION_DOWN + call(4, action=1), # ACTION_UP + ] + + def test_text_ascii_uses_inject_text(self, ctrl: ScrcpyController): + """纯 ASCII 文本走 INJECT_TEXT 低延迟路径。""" + ctrl._inject_text = MagicMock() + ctrl._set_clipboard = MagicMock() + ctrl.text('hello', delay=False) + ctrl._inject_text.assert_called_once_with('hello') + ctrl._set_clipboard.assert_not_called() + + def test_text_non_ascii_uses_clipboard(self, ctrl: ScrcpyController): + """含中文等多字节字符走 SET_CLIPBOARD + paste。""" + ctrl._inject_text = MagicMock() + ctrl._set_clipboard = MagicMock() + ctrl.text('你好', delay=False) + ctrl._set_clipboard.assert_called_once_with('你好', paste=True) + ctrl._inject_text.assert_not_called() + + def test_send_control_raises_when_disconnected(self, ctrl: ScrcpyController): + """控制通道未连接时 _send_control 抛异常。""" ctrl._control_socket = None ctrl._alive = False ctrl._ensure_stream_alive = MagicMock(side_effect=EmulatorConnectionError('mock')) From 83f4d8ad7b8e52acc8a055d75a26199d47327ce7 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:37:43 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(auto=5Fdaily):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=85=A8=E5=A4=A9=E6=8C=82=E6=9C=BA=E8=B0=83=E5=BA=A6=E5=8F=8A?= =?UTF-8?q?=E6=B5=B4=E5=AE=A4/=E8=A7=A3=E8=A3=85/=E6=88=98=E5=BD=B9?= =?UTF-8?q?=E6=94=AF=E6=8F=B4=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 恢复 scheduler (daily_plan / scheduler / triggers) 全天挂机优先级队列调度 - 新增 / 恢复 ops: campaign / destroy / event_fight / exercise / normal_fight / repair / decisive.chapter - 新增 bathroom context + bath_page UI 页面; types 同步调度所需枚举 Co-Authored-By: Claude --- autowsgr/context/bathroom.py | 88 +++++++ autowsgr/context/game_context.py | 3 + autowsgr/ops/campaign.py | 30 +++ autowsgr/ops/decisive/chapter.py | 9 +- autowsgr/ops/destroy.py | 45 +++- autowsgr/ops/event_fight.py | 9 +- autowsgr/ops/exercise.py | 29 ++- autowsgr/ops/normal_fight.py | 39 ++-- autowsgr/ops/repair.py | 63 ++++- autowsgr/scheduler/__init__.py | 14 +- autowsgr/scheduler/daily_plan.py | 268 ++++++++++++++++++++++ autowsgr/scheduler/scheduler.py | 180 ++++++++++++++- autowsgr/scheduler/triggers.py | 380 +++++++++++++++++++++++++++++++ autowsgr/types.py | 3 + autowsgr/ui/bath_page/page.py | 43 ++++ examples/auto_daily.py | 117 ++++++++++ 16 files changed, 1284 insertions(+), 36 deletions(-) create mode 100644 autowsgr/context/bathroom.py create mode 100644 autowsgr/scheduler/daily_plan.py create mode 100644 autowsgr/scheduler/triggers.py create mode 100644 examples/auto_daily.py diff --git a/autowsgr/context/bathroom.py b/autowsgr/context/bathroom.py new file mode 100644 index 00000000..c14cde7e --- /dev/null +++ b/autowsgr/context/bathroom.py @@ -0,0 +1,88 @@ +"""浴室修理槽位状态 — 跟踪各修理槽的释放时间。 + +移植自 classic ``port/common.py::BathRoom`` 的状态机, 但**不移植其逐槽像素 +扫描** (classic 在 ``task_runner.py`` 用硬编码坐标 + 「快速修理」OCR 文字 +逐个点开槽位读取剩余时间)。dev 浴室页面用像素签名模型, 坐标与判定方式 +不同, 逐槽扫描无法直接复用且无法离线验证。 + +替代方案 (等价空位调度) +----------------------- +``available_time`` 仅跟踪**本脚本派出的修理**: :meth:`BathRoom.occupy` 在 +成功派修后记录该槽释放时间。配合 ``BathPage`` 选择修理 overlay 的点击反馈 +(``_try_wait_overlay_close``: overlay 关闭 = 修成功 / 未关 = 浴场满) 实现: + +- 有空闲槽才派修 (:meth:`is_available`); +- 跟踪已派修理的进度 (释放时间戳自然到期 = 槽回收); +- 浴场满则 :meth:`mark_unknown` 退避, 下次触发重试。 + +启动时状态未知 (``None``), 首次触发尝试派修即可恢复。纯内存不持久化 +(与 classic 一致)。 +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + + +@dataclass +class BathRoom: + """浴室修理槽位状态机。 + + Attributes + ---------- + available_time: + 每个修理槽的绝对释放时间戳 (``time.time()`` 基); ``None`` 表示状态 + 未知 (需尝试派修以重建)。元素值 ``<= now`` 表示该槽空闲。 + slot_count: + 修理槽总数 (来自 ``config.bathroom_count``), 用于初始化槽位数。 + """ + + available_time: list[float] | None = None + slot_count: int = 2 + + def _ensure_initialized(self) -> None: + """首次使用时按 slot_count 初始化为全空闲。""" + if self.available_time is None: + self.available_time = [0.0] * max(1, self.slot_count) + + def is_available(self) -> bool: + """是否有空闲槽位。状态未知时返回 True (允许尝试派修)。""" + if self.available_time is None: + return True + return self.get_waiting_time() == 0.0 + + def get_waiting_time(self) -> float: + """最近一个槽释放还需等待的秒数; ``0.0`` 表示已有空位。 + + 状态未知 (``None``) 返回 ``0.0``。 + """ + if self.available_time is None: + return 0.0 + now = time.time() + waiting = float('inf') + for release in self.available_time: + if now >= release: + return 0.0 + waiting = min(waiting, release - now) + return waiting + + def occupy(self, repair_seconds: int) -> None: + """占用一个空闲槽: 记录其释放时间 = ``now + repair_seconds``。 + + 若状态未初始化会先初始化; 若无空闲槽则静默忽略 (调用方应先判 + :meth:`is_available`)。 + """ + self._ensure_initialized() + assert self.available_time is not None + if repair_seconds <= 0: + return + now = time.time() + for i, release in enumerate(self.available_time): + if now >= release: + self.available_time[i] = now + repair_seconds + return + + def mark_unknown(self) -> None: + """标记状态未知, 强制下次触发重新尝试派修。""" + self.available_time = None diff --git a/autowsgr/context/game_context.py b/autowsgr/context/game_context.py index 4269b619..01f0393a 100644 --- a/autowsgr/context/game_context.py +++ b/autowsgr/context/game_context.py @@ -9,6 +9,7 @@ from autowsgr.infra.logger import get_logger from autowsgr.types import ShipDamageState +from .bathroom import BathRoom from .build import BuildQueue from .expedition import ExpeditionQueue from .fleet import Fleet @@ -79,6 +80,8 @@ class GameContext: """远征队列。""" build_queue: BuildQueue = field(default_factory=BuildQueue) """建造队列。""" + bathroom: BathRoom = field(default_factory=BathRoom) + """浴室修理槽位状态 (空位调度用)。""" ship_registry: dict[str, Ship] = field(default_factory=dict) """舰船注册表, 以名称为键。""" current_page: PageName | None = None diff --git a/autowsgr/ops/campaign.py b/autowsgr/ops/campaign.py index a59aa7ec..8ce5449f 100644 --- a/autowsgr/ops/campaign.py +++ b/autowsgr/ops/campaign.py @@ -44,6 +44,31 @@ } """战役编号 → 中文名称。""" + +def ensure_battle_support(ctx: GameContext, *, enabled: bool = True) -> None: + """确保战役支援开关处于目标状态 (在出征准备页面调用)。 + + 读取当前支援状态, 不符则切换。对应 classic ``set_support``: + 受 ``daily_automation.auto_set_support`` 控制, 由 + :meth:`CampaignRunner._prepare_for_battle` 在出征前一次性调用。 + + Parameters + ---------- + ctx: + 游戏上下文 (需已在出征准备页面)。 + enabled: + 目标状态, 默认 ``True`` (开启)。 + """ + page = BattlePreparationPage(ctx) + screen = ctx.ctrl.screenshot() + if page.is_support_enabled(screen) == enabled: + _log.debug('[OPS] 战役支援已为目标状态 {}', enabled) + return + _log.info('[OPS] 战役支援状态不符, 切换 → {}', enabled) + page.toggle_battle_support() + time.sleep(0.5) + + # 用户友好的战役名称 → (map_index, difficulty) # 支持 "困难航母"、"简单驱逐" 等名称直接映射 CAMPAIGN_NAME_MAP: dict[str, tuple[int, str]] = {} @@ -218,6 +243,11 @@ def _prepare_for_battle(self) -> tuple[list[ShipDamageState], bool]: time.sleep(0.25) # 等待页面稳定 page = BattlePreparationPage(self._ctx) + # 战役支援 (auto_set_support): 出征前一次性开启 + da = self._ctx.config.daily_automation + if da is not None and da.auto_set_support: + ensure_battle_support(self._ctx, enabled=True) + # 修理策略 if self._repair_mode == RepairMode.moderate_damage: page.apply_repair(RepairStrategy.MODERATE) diff --git a/autowsgr/ops/decisive/chapter.py b/autowsgr/ops/decisive/chapter.py index 50d55e08..839d3d18 100644 --- a/autowsgr/ops/decisive/chapter.py +++ b/autowsgr/ops/decisive/chapter.py @@ -33,13 +33,12 @@ def _prepare_entry_state(self) -> None: def _do_dock_full_destroy(self) -> None: """船坞满处理:按配置自动解装或抛错。""" if self._config.full_destroy: - from autowsgr.ops.destroy import destroy_ships + from autowsgr.ops.destroy import destroy_ships_auto _log.warning('[决战] 船坞已满,执行自动解装') self._ctrl.click(0.38, 0.565) - destroy_ships( - self._ctx, - ship_types=self._ctx.config.destroy_ship_types or None, - ) + if not destroy_ships_auto(self._ctx): + # 白名单覆盖全部舰种, 无可解装对象, 船坞仍满 + raise DockFullError('决战中船坞已满,且无可解装舰种') return raise DockFullError('决战中检测到船坞已满,且未开启 full_destroy') diff --git a/autowsgr/ops/destroy.py b/autowsgr/ops/destroy.py index 3e7adbf4..43959bca 100644 --- a/autowsgr/ops/destroy.py +++ b/autowsgr/ops/destroy.py @@ -12,7 +12,7 @@ from autowsgr.infra.logger import get_logger from autowsgr.ops.navigate import goto_page -from autowsgr.types import PageName, ShipType +from autowsgr.types import DestroyShipWorkMode, PageName, ShipType if TYPE_CHECKING: @@ -49,3 +49,46 @@ def destroy_ships( goto_page(ctx, PageName.MAIN) _log.info('[OPS] 解装完成') + + +def destroy_ships_auto(ctx: GameContext) -> bool: + """按 ``ctx.config`` 的解装设置自动解装。 + + 供 normal_fight / event_fight / decisive 船坞满时调用, 统一读取配置。 + 是否调用本函数由调用方的 ``dock_full_destroy`` / ``full_destroy`` 开关决定; + 此处只关心「怎么拆」:: + + - ``destroy_ship_work_mode == disable``: 不启用舰种分类, 走快速拆解路线 + (``ship_types=None`` → 不打开过滤器, 快速全选解装全部)。 + - ``include`` (黑名单): 解装 ``destroy_ship_types`` 指定舰种。 + - ``exclude`` (白名单): 解装除 ``destroy_ship_types`` 外的所有舰种。 + + ``remove_equipment`` 取自 ``remove_equipment_mode``。 + + Returns + ------- + bool + ``True`` 已执行解装; ``False`` 仅在白名单覆盖全部舰种、无可解装对象时返回 + (此时船坞仍满, 调用方据此保持 DOCK_FULL)。 + """ + cfg = ctx.config + mode = cfg.destroy_ship_work_mode + + if mode == DestroyShipWorkMode.disable: + # 不启用舰种分类: 不过滤, 直接走快速全选拆解路线 + ship_types = None + elif mode == DestroyShipWorkMode.include: + ship_types = cfg.destroy_ship_types or None + else: # exclude (白名单): 解装除指定舰种外的所有 + protected = set(cfg.destroy_ship_types) + ship_types = [t for t in ShipType if t is not ShipType.Other and t not in protected] + if not ship_types: + _log.warning('[OPS] 白名单包含全部舰种, 无可解装对象, 跳过') + return False + + destroy_ships( + ctx, + ship_types=ship_types, + remove_equipment=cfg.remove_equipment_mode, + ) + return True diff --git a/autowsgr/ops/event_fight.py b/autowsgr/ops/event_fight.py index ed337bdb..3d7c56d2 100644 --- a/autowsgr/ops/event_fight.py +++ b/autowsgr/ops/event_fight.py @@ -320,15 +320,12 @@ def _handle_result(self, result: CombatResult) -> None: def _handle_dock_full(self, result: CombatResult) -> None: """船坞已满处理。""" if self._dock_full_destroy: - from autowsgr.ops.destroy import destroy_ships + from autowsgr.ops.destroy import destroy_ships_auto _log.warning('[OPS] 船坞已满,执行自动解装') self._ctrl.click(0.38, 0.565) - destroy_ships( - self._ctx, - ship_types=self._destroy_ship_types, - ) - result.flag = ConditionFlag.OPERATION_SUCCESS + if destroy_ships_auto(self._ctx): + result.flag = ConditionFlag.OPERATION_SUCCESS return _log.warning('[OPS] 船坞已满, 未开启自动解装') diff --git a/autowsgr/ops/exercise.py b/autowsgr/ops/exercise.py index 7ba965ec..828b4a98 100644 --- a/autowsgr/ops/exercise.py +++ b/autowsgr/ops/exercise.py @@ -14,7 +14,7 @@ from autowsgr.combat import CombatMode, CombatPlan, CombatResult, NodeDecision, run_combat from autowsgr.infra.logger import get_logger from autowsgr.ops.navigate import goto_page -from autowsgr.types import Formation, PageName, ShipDamageState +from autowsgr.types import ConditionFlag, Formation, PageName, ShipDamageState from autowsgr.ui import BattlePreparationPage, MapPage, MapPanel @@ -129,6 +129,33 @@ def _do_combat(self, ship_stats: list[ShipDamageState]) -> CombatResult: return result +class ExerciseOnceRunner(ExerciseRunner): + """单次演习执行器 (auto_daily 触发器用)。 + + 与 :class:`ExerciseRunner` 不同: 每次 :meth:`run` 只挑战**一个**可用的对手, + 无可用对手时返回 ``SKIP_FIGHT`` —— 供 :class:`~autowsgr.scheduler.triggers.ExerciseTrigger` + 判定本时段已打满 (跨时段再 reset)。 + + 这样把「打完 5 场」从 runner 内部循环上移到触发器层, 场与场之间允许 + 远征等高优先级任务插队。 + """ + + def run(self) -> CombatResult: # type: ignore[override] + """挑战下一个可用对手; 无对手返回 ``SKIP_FIGHT``。""" + self._enter_exercise_page() + rivals_status = MapPage(self._ctx).get_exercise_rival_status() + rivals = rivals_status.rivals + _log.info('[OPS] 单次演习, 当前可挑战对手: {}', rivals) + + for index, available in enumerate(rivals, start=1): + if available: + _log.info('[OPS] 单次演习: 挑战对手 {}', index) + return self._challenge_rival(index) + + _log.info('[OPS] 无可挑战对手, 本时段演习已打满') + return CombatResult(flag=ConditionFlag.SKIP_FIGHT) + + def run_exercise( ctx: GameContext, fleet_id: int = 1, diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index 242c52ee..c4dca59d 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: + from pathlib import Path + from autowsgr.context import GameContext from autowsgr.context.ship import Ship @@ -403,17 +405,15 @@ def _handle_result(self, result: CombatResult) -> None: def _handle_dock_full(self, result: CombatResult) -> None: """船坞已满: 按配置自动解装并重试,或保持 DOCK_FULL 标志。""" if self._dock_full_destroy: - from autowsgr.ops.destroy import destroy_ships + from autowsgr.ops.destroy import destroy_ships_auto _log.warning('[OPS] 船坞已满,执行自动解装') # 点击弹窗确认按钮 (legacy 坐标) self._ctrl.click(0.38, 0.565) - destroy_ships( - self._ctx, - ship_types=self._destroy_ship_types, - ) - # 解装后标记为成功 (调用方可根据需要重试出征) - result.flag = ConditionFlag.OPERATION_SUCCESS + if destroy_ships_auto(self._ctx): + # 解装成功 (调用方可根据需要重试出征) + result.flag = ConditionFlag.OPERATION_SUCCESS + # 否则无可解装对象 (白名单覆盖全部舰种), 保持 DOCK_FULL return _log.warning('[OPS] 船坞已满, 未开启自动解装') @@ -425,11 +425,22 @@ def _handle_dock_full(self, result: CombatResult) -> None: # ═══════════════════════════════════════════════════════════════════════════════ -def get_normal_fight_plan(yaml_path: str) -> CombatPlan: - """从 YAML 文件加载常规战计划。""" +def get_normal_fight_plan( + yaml_path: str, + plan_root: str | Path | None = None, +) -> CombatPlan: + """从 YAML 文件加载常规战计划。 + + *plan_root* 透传给 :func:`resolve_plan_path`: 用户自定义目录优先于包内默认, + 未命中则回退到 ``autowsgr/data/plan/normal_fight/``。 + """ from autowsgr.infra.file_utils import resolve_plan_path - resolved = resolve_plan_path(yaml_path, category='normal_fight') + resolved = resolve_plan_path( + yaml_path, + category='normal_fight', + plan_root=plan_root, + ) return CombatPlan.from_yaml(resolved) @@ -462,16 +473,18 @@ def run_normal_fight_from_yaml( fleet_id: int | None = None, fleet: list[str] | None = None, fleet_rules: list[Any] | None = None, + plan_root: str | Path | None = None, ) -> list[CombatResult]: """从 YAML 文件加载计划并执行常规战。 *yaml_path* 支持以下格式: - 绝对路径 / 相对路径: 直接加载。 - - 策略名称 (如 ``"7-4千伪"``): 自动在 ``autowsgr/data/plan/normal_fight/`` - 包数据目录中查找,可省略 ``.yaml`` 后缀。 + - 策略名称 (如 ``"7-4千伪"``): 按 :func:`resolve_plan_path` 的优先级查找 —— + 若指定 *plan_root* 先在其中查找 (``{plan_root}/normal_fight/``), 未命中再 + 回退到 ``autowsgr/data/plan/normal_fight/`` 包数据目录; 可省略 ``.yaml`` 后缀。 """ - plan = get_normal_fight_plan(yaml_path) + plan = get_normal_fight_plan(yaml_path, plan_root=plan_root) return run_normal_fight( ctx, plan, diff --git a/autowsgr/ops/repair.py b/autowsgr/ops/repair.py index b28cdae5..63056853 100644 --- a/autowsgr/ops/repair.py +++ b/autowsgr/ops/repair.py @@ -10,6 +10,7 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger @@ -43,8 +44,6 @@ def repair_in_bath(ctx: GameContext) -> None: page.click_repair_all() # 点击全部修理后 overlay 已关闭,先回到浴室页面,再返回主界面 - import time - time.sleep(1.0) try: goto_page(ctx, PageName.MAIN) @@ -82,3 +81,63 @@ def repair_ship_by_name(ctx: GameContext, ship_name: str) -> int: _log.warning('[OPS] 浴场已满, 无法修理 {}', ship_name) return repair_secs + + +def repair_one_available( + ctx: GameContext, + *, + blacklist: list[str] | None = None, +) -> bool: + """有空闲修理槽时, 派修修理时间最长的非黑名单舰船 (调度入口)。 + + 由 ``auto_daily`` 调度器以 :class:`~autowsgr.scheduler.triggers.TimerTrigger` + 周期产出, 经优先级队列在所有战斗任务 (战役/演习/常规战) 完成后才执行 + (空闲修船, 见 ``daily_plan.PRIO_BATH_REPAIR``)。流程: + + 1. ``ctx.bathroom`` 无空闲槽 → 直接返回 (省一次开 overlay)。 + 2. 导航浴室 → 开选择修理 overlay → 修最长非黑名单船。 + 3. 成功 → ``ctx.bathroom.occupy`` 记录释放时间, 返回 ``True``。 + 浴场满 → ``mark_unknown`` 退避, 返回 ``False``。 + + Parameters + ---------- + ctx: + 游戏上下文。 + blacklist: + 不修理的舰船名列表 (来自 ``daily_automation.bath_repair_blacklist``)。 + + Returns + ------- + bool + ``True`` 成功派出一艘修理; ``False`` 无空位 / 无可修船 / 浴场满。 + """ + bath = ctx.bathroom + bath.slot_count = ctx.config.bathroom_count + if not bath.is_available(): + _log.debug('[OPS] 浴室无空闲槽, 跳过修理') + return False + + goto_page(ctx, PageName.BATH) + page = BathPage(ctx) + page.go_to_choose_repair() + secs = page.repair_longest(blacklist=set(blacklist or [])) + + if secs > 0: + bath.occupy(secs) + _log.info('[OPS] 浴室修理派单成功 ({}s)', secs) + result = True + elif secs == -2: + # 浴场满: 状态不可靠, 标记未知下次重试 + bath.mark_unknown() + _log.warning('[OPS] 浴场已满, 稍后重试') + result = False + else: + # secs == -1: 无可修候选, 状态不变 + result = False + + time.sleep(1.0) + try: + goto_page(ctx, PageName.MAIN) + except Exception: + _log.warning('[OPS] 浴室修理后返回主界面失败') + return result diff --git a/autowsgr/scheduler/__init__.py b/autowsgr/scheduler/__init__.py index 553fe119..48e7c9d8 100644 --- a/autowsgr/scheduler/__init__.py +++ b/autowsgr/scheduler/__init__.py @@ -4,8 +4,9 @@ - :func:`launch` — 加载配置 → 连接模拟器 → 启动游戏 → 返回就绪的 GameContext - :class:`Launcher` — 可定制的启动器 -- :class:`TaskScheduler` — 基础任务调度器 (顺序执行 + 远征定时检查) +- :class:`TaskScheduler` — 基础任务调度器 (顺序执行 + 远征定时检查 + 触发器调度) - :class:`FightTask` — 战斗任务描述 +- :func:`build_daily_plan` — 把 ``daily_automation`` 配置翻译成触发器 (auto_daily) 典型用法:: @@ -15,8 +16,18 @@ scheduler = TaskScheduler(ctx, expedition_interval=900) scheduler.add(FightTask(runner=my_runner, times=30)) scheduler.run() + +auto_daily 全天挂机:: + + from autowsgr.scheduler import launch, TaskScheduler, build_daily_plan + + ctx = launch("user_settings.yaml") + scheduler = TaskScheduler(ctx, expedition_interval=0) # 远征交给触发器 + build_daily_plan(scheduler, ctx) + scheduler.run_daily() """ +from .daily_plan import build_daily_plan from .launcher import Launcher, launch from .scheduler import BatchRunnerAdapter, FightTask, TaskScheduler @@ -26,5 +37,6 @@ 'FightTask', 'Launcher', 'TaskScheduler', + 'build_daily_plan', 'launch', ] diff --git a/autowsgr/scheduler/daily_plan.py b/autowsgr/scheduler/daily_plan.py new file mode 100644 index 00000000..6d0af417 --- /dev/null +++ b/autowsgr/scheduler/daily_plan.py @@ -0,0 +1,268 @@ +"""auto_daily 计划构建 — 把 :class:`DailyAutomationConfig` 翻译成触发器。 + +激活 dev 的「死配置」``daily_automation``: 读取其字段, 为每个启用的日常任务 +(远征 / 战役 / 演习 / 常规战) 构造触发器并注册到 :class:`TaskScheduler`。 + +优先级 (数值小先执行, 由 :mod:`autowsgr.scheduler.triggers` 定义):: + + 远征 0 < 奖励 1 < 战役 5 < 演习 10 < 常规战 100 < 浴室修理 200 (空闲填充, 最后执行) + +使用方式:: + + from autowsgr.scheduler import launch, TaskScheduler, build_daily_plan + + ctx = launch("user_settings.yaml") + scheduler = TaskScheduler(ctx, expedition_interval=0) # 旧远征检查交给触发器 + build_daily_plan(scheduler, ctx) + scheduler.run_daily() +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from autowsgr.combat import CombatResult +from autowsgr.infra.logger import get_logger +from autowsgr.scheduler.triggers import ( + CampaignTrigger, + ExerciseTrigger, + ExpeditionTrigger, + NormalFightPlan, + NormalFightTrigger, + TimerTrigger, +) +from autowsgr.types import ConditionFlag + + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from autowsgr.context import GameContext + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.scheduler.scheduler import TaskScheduler + +_log = get_logger('scheduler') + +# 优先级常量 (数值越小越先执行) +PRIO_EXPEDITION = 0 +PRIO_BONUS = 1 +PRIO_CAMPAIGN = 5 +PRIO_EXERCISE = 10 +PRIO_NORMAL_FIGHT = 100 +# 浴室修理: 空闲填充, 排在所有战斗任务之后 —— 仅当战役/演习/常规战都无 pending +# 任务 (常规战 _is_exhausted、战役演习 _exhausted) 时才出队执行, 还原 classic +# "所有战斗 (含常规战) 完成后才修船" 的语义。与 NormalFightTrigger 用 prio=100 +# 实现"空闲填充"同理, 只是浴室修理比常规战更晚 (优先级更低)。 +PRIO_BATH_REPAIR = 200 + +# 远征定时触发间隔 (秒)。collect_expedition 内部会先检查是否有远征可收取, +# 无则空转返回, 故可较短; 默认 10 分钟轮询一次。 +DEFAULT_EXPEDITION_INTERVAL = 600.0 + + +class _FunctionRunner: + """把任意 ``fn(ctx)`` 包成 ``run() -> CombatResult`` 的 runner。 + + 远征等非战斗任务没有 CombatResult 语义, 这里统一返回 ``OPERATION_SUCCESS``; + 异常被吞掉并记录 (远征失败不应中断整日挂机)。 + """ + + def __init__(self, fn: Callable[[], object], name: str = '远征') -> None: + self._fn = fn + self._name = name + + def run(self) -> CombatResult: + try: + self._fn() + except Exception as exc: + _log.opt(exception=True).warning('[daily] {} 执行异常: {}', self._name, exc) + return CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + + +def build_daily_plan( + scheduler: TaskScheduler, + ctx: GameContext, + *, + expedition_interval: float = DEFAULT_EXPEDITION_INTERVAL, +) -> None: + """读取 ``ctx.config.daily_automation``, 注册各触发器到 ``scheduler``。 + + Parameters + ---------- + scheduler: + 目标调度器。 + ctx: + 游戏上下文 (提供 config / 控制器)。 + expedition_interval: + 远征定时触发的轮询间隔 (秒)。 + """ + cfg = ctx.config.daily_automation + if cfg is None: + _log.warning('[daily] 未配置 daily_automation, 不注册任何触发器') + return + + _log.info('[daily] 构建 auto_daily 计划') + + # ── 远征 (定时, prio 0) ── + if cfg.auto_expedition: + from autowsgr.ops.expedition import collect_expedition + + scheduler.register_trigger( + ExpeditionTrigger( + task_factory=lambda c: _FunctionRunner( + lambda: collect_expedition(c), + name='远征', + ), + priority=PRIO_EXPEDITION, + name='远征', + interval=expedition_interval, + ), + ) + + # ── 任务奖励 (定时, prio 1, 随远征周期) ── + if cfg.auto_gain_bonus: + from autowsgr.ops.reward import collect_rewards + + scheduler.register_trigger( + TimerTrigger( + task_factory=lambda c: _FunctionRunner( + lambda: collect_rewards(c), + name='任务奖励', + ), + priority=PRIO_BONUS, + name='任务奖励', + interval=expedition_interval, + ), + ) + + # ── 浴室修理 (定时, prio 200, 空闲填充: 所有战斗完成后才执行) ── + if cfg.auto_bath_repair: + from autowsgr.ops.repair import repair_one_available + + scheduler.register_trigger( + TimerTrigger( + task_factory=lambda c: _FunctionRunner( + lambda: repair_one_available( + c, + blacklist=cfg.bath_repair_blacklist, + ), + name='浴室修理', + ), + priority=PRIO_BATH_REPAIR, + name='浴室修理', + interval=expedition_interval, + ), + ) + + # ── 战役 (条件, prio 5) ── + if cfg.auto_battle: + from autowsgr.ops.campaign import CampaignRunner + + battle_type = cfg.battle_type + scheduler.register_trigger( + CampaignTrigger( + # times=1: 每个任务打一场, 场间允许远征插队; 次数耗尽由 BATTLE_TIMES_EXCEED 自适应 + task_factory=lambda c: CampaignRunner(c, battle_type, times=1), + priority=PRIO_CAMPAIGN, + name=f'战役/{battle_type}', + ), + ) + + # ── 演习 (条件, prio 10) ── + if cfg.auto_exercise: + from autowsgr.ops.exercise import ExerciseOnceRunner + + fleet_id = cfg.exercise_fleet_id or 1 + scheduler.register_trigger( + ExerciseTrigger( + task_factory=lambda c: ExerciseOnceRunner(c, fleet_id), + priority=PRIO_EXERCISE, + name='演习', + ), + ) + + # ── 常规战 (条件, prio 100, 空闲填充) ── + if cfg.auto_normal_fight and cfg.normal_fight_tasks: + _register_normal_fight(scheduler, cfg, plan_root=ctx.config.plan_root) + + # ── 浴室修理被无限常规战抢占的告警 ── + # 改 prio 后浴室修理 (200) 排在常规战 (100) 之后; 若存在无限常规战 + # (times=None) 且未开任何停止上限, 常规战会持续产出 prio=100 任务、 + # 永远抢占浴室修理致其无声失效。这是 classic 没有的 dev 新场景, 显式提示。 + if _bath_repair_starved_by_normal_fight(cfg): + _log.warning( + '[daily] 已启用浴室修理, 但存在无限常规战 (times 未设置) 且未开启任何' + '停止上限 (stop_max_ship / stop_max_loot / quick_repair_limit)。' + '常规战会持续抢占浴室修理, 后者将永远不会执行。请为常规战设置 times ' + '或开启任一停止上限。', + ) + + if not scheduler._triggers: + _log.warning('[daily] 未启用任何日常任务, run_daily 将空转') + + +def _bath_repair_starved_by_normal_fight(cfg: DailyAutomationConfig) -> bool: + """无限常规战是否会持续抢占浴室修理、致其永不执行。 + + 条件: 启用浴室修理 + 启用常规战 + 存在 ``times=None`` 的常规战任务 + + 三个停止上限 (stop_max_ship / stop_max_loot / quick_repair_limit) 全关。 + 抽成纯函数便于单测。 + """ + return ( + cfg.auto_bath_repair + and cfg.auto_normal_fight + and bool(cfg.normal_fight_tasks) + and any(task.times is None for task in cfg.normal_fight_tasks) + and not (cfg.stop_max_ship or cfg.stop_max_loot or cfg.quick_repair_limit) + ) + + +def _register_normal_fight( + scheduler: TaskScheduler, + cfg: DailyAutomationConfig, + *, + plan_root: str | Path | None = None, +) -> None: + """把 normal_fight_tasks 翻译成 NormalFightPlan 列表并注册触发器。 + + *plan_root* 透传给 :func:`get_normal_fight_plan`, 用户自定义目录优先。 + """ + from autowsgr.ops.normal_fight import NormalFightRunner, get_normal_fight_plan + + plans: list[NormalFightPlan] = [] + for task in cfg.normal_fight_tasks: + try: + plan = get_normal_fight_plan(task.name, plan_root=plan_root) + except Exception as exc: + _log.opt(exception=True).warning( + '[daily] 无法解析常规战计划 {!r}, 跳过: {}', + task.name, + exc, + ) + continue + fleet_id = task.fleet_id or plan.fleet_id or 1 + plans.append( + NormalFightPlan( + # 默认参数捕获 plan/fleet, 避免闭包晚绑定 + factory=lambda c, p=plan, f=fleet_id: NormalFightRunner(c, p, fleet_id=f), + name=task.name, + fleet_id=fleet_id, + target=task.times, # None = 无限 (空闲填充) + ), + ) + + if not plans: + _log.warning('[daily] 常规战任务列表为空或全部解析失败, 跳过常规战触发器') + return + + scheduler.register_trigger( + NormalFightTrigger( + priority=PRIO_NORMAL_FIGHT, + name='常规战', + plans=plans, + stop_max_ship=cfg.stop_max_ship, + stop_max_loot=cfg.stop_max_loot, + quick_repair_limit=cfg.quick_repair_limit, + ), + ) diff --git a/autowsgr/scheduler/scheduler.py b/autowsgr/scheduler/scheduler.py index 6fce6c11..d6e95c70 100644 --- a/autowsgr/scheduler/scheduler.py +++ b/autowsgr/scheduler/scheduler.py @@ -24,6 +24,7 @@ import time from dataclasses import dataclass, field +from datetime import date from typing import TYPE_CHECKING, Protocol, runtime_checkable from autowsgr.combat import CombatResult @@ -32,7 +33,10 @@ if TYPE_CHECKING: + from collections.abc import Callable + from autowsgr.context import GameContext + from autowsgr.scheduler.triggers import Trigger _log = get_logger('scheduler') @@ -101,11 +105,18 @@ class FightTask: 执行次数。``CampaignRunner`` 自带 times 时此处设 1 即可。 name: 任务名称(用于日志),留空则自动推导。 + priority: + 优先级(数值越小越先执行),用于 ``run_daily`` 触发器队列排序。默认 50。 + on_done: + 每场战斗结束后的回调(接收 ``CombatResult``)。触发器用它更新 + ``_idle`` / ``_exhausted`` 等内部状态。 """ runner: object times: int = 1 name: str = '' + priority: int = 50 + on_done: Callable[[CombatResult], None] | None = None # 运行时状态 completed: int = field(default=0, init=False, repr=False) @@ -138,11 +149,17 @@ def __init__( ctx: GameContext, *, expedition_interval: float = 900.0, + idle_sleep: float = 5.0, ) -> None: self._ctx = ctx self._expedition_interval = expedition_interval + self._idle_sleep = idle_sleep self._tasks: list[FightTask] = [] self._last_expedition_time: float = 0.0 + # 触发器调度 (auto_daily 长期挂机) + self._triggers: list[Trigger] = [] + self._queue: list[FightTask] = [] + self._last_date: date = date.today() # noqa: DTZ011 # 跨日按本地墙上时钟 (游戏 0 点刷新) # ── 任务管理 ── @@ -196,14 +213,21 @@ def run(self) -> list[FightTask]: def _run_task(self, task: FightTask) -> None: """执行单个任务的全部轮次。""" - # 适配返回 list 的 runner - runner = task.runner - if not isinstance(runner, FightRunnerProtocol): - runner = BatchRunnerAdapter(runner) + # 统一用 BatchRunnerAdapter 包装: 对 run()→list[CombatResult] 取最后一场, + # 对 run()→单个 CombatResult 直接 passthrough, 兼容两类 runner + # (CampaignRunner / ExerciseRunner 返回 list, 其余返回单个)。 + # 不能靠 isinstance(FightRunnerProtocol) 判断: @runtime_checkable 不检查 + # 返回类型, CampaignRunner 有 run() 方法即被误判满足协议而跳过适配 + # (曾致战役 on_done 回调 'list' object has no attribute 'flag' 崩溃)。 + runner = BatchRunnerAdapter(task.runner) self._ctx.active_fight_tasks += 1 try: for j in range(task.times): + if self._ctx.stop_event.is_set(): + _log.info('[Scheduler] {} 检测到停止信号, 中断', task.name) + break + _log.info( '[Scheduler] {} 第 {}/{} 次', task.name, @@ -211,23 +235,37 @@ def _run_task(self, task: FightTask) -> None: task.times, ) - # 远征检查 (战斗前) + # 远征检查 (战斗前) — 仅旧 run() 路径有效;run_daily() 下由触发器接管 self._maybe_collect_expedition() try: result = runner.run() except Exception as exc: + # 子任务异常: 结束本子任务, 不崩溃主循环。ACTION_FAILED 不属 + # 于任何触发器的成功/耗尽标志, 故 on_done 不会计入战斗次数、 + # 不会误判耗尽 —— 远征出错等下次定时重试, 战斗出错不扣次数。 _log.opt(exception=True).error( - '[Scheduler] {} 第 {} 次异常: {}', + '[Scheduler] {} 第 {} 次异常, 结束本子任务: {}', task.name, j + 1, exc, ) - result = CombatResult(flag=ConditionFlag.DOCK_FULL) + result = CombatResult(flag=ConditionFlag.ACTION_FAILED) task.results.append(result) task.completed += 1 + # 通知触发器更新状态 (auto_daily 触发器调度用) + if task.on_done is not None: + try: + task.on_done(result) + except Exception as exc: + _log.opt(exception=True).warning( + '[Scheduler] {} on_done 回调异常: {}', + task.name, + exc, + ) + _log.info( '[Scheduler] {} [{}/{}] → {}', task.name, @@ -247,6 +285,134 @@ def _run_task(self, task: FightTask) -> None: finally: self._ctx.active_fight_tasks -= 1 + # ═══════════════════════════════════════════════════════════════════════════════ + # 触发器调度 (auto_daily 长期挂机) + # ═══════════════════════════════════════════════════════════════════════════════ + + def register_trigger(self, trigger: Trigger) -> TaskScheduler: + """注册一个触发器。支持链式调用。""" + self._triggers.append(trigger) + _log.info( + '[Scheduler] 注册触发器: {} (prio={})', + trigger.name, + trigger.priority, + ) + return self + + def run_daily(self) -> None: + """触发器驱动的长期挂机主循环 (auto_daily)。 + + 循环逻辑:: + + while not stop_event: + 1. 检测跨日 → reset 所有触发器 (战役 _exhausted / 常规战计数清零) + 2. 询问每个触发器 should_fire → 命中的任务按 priority 入队 + 3. 队首有任务 → 执行;队列空 → idle_sleep 后继续 (挂机等待) + + 与 :meth:`run` 的区别:后者顺序执行预提交任务后退出; + 本方法由触发器持续产出任务,适合全天 / 跨日挂机。 + """ + _log.info( + '[Scheduler] 开始触发器调度: {} 个触发器', + len(self._triggers), + ) + self._last_date = date.today() # noqa: DTZ011 # 本地墙上时钟 + + while not self._ctx.stop_event.is_set(): + self._check_daily_reset() + + # 收集触发器产出的新任务 + for trigger in self._triggers: + try: + task = trigger.should_fire(self._ctx) + except Exception as exc: + _log.opt(exception=True).warning( + '[Scheduler] 触发器 {} 异常: {}', + trigger.name, + exc, + ) + continue + if task is not None: + self._enqueue(task) + + # 取队首执行 + task = self._dequeue() + if task is not None: + try: + self._run_task(task) + except Exception as exc: + # _run_task 自身异常 (非 runner.run, 极少见) 兜底: 复位触发器 + # _idle 避免该触发器卡死不再产出, 继续主循环, 不崩溃脚本。 + _log.opt(exception=True).error( + '[Scheduler] 子任务 {} 执行崩溃, 已跳过: {}', + task.name, + exc, + ) + if task.on_done is not None: + try: + task.on_done( + CombatResult(flag=ConditionFlag.ACTION_FAILED), + ) + except Exception: # noqa: S110 # on_done 回调异常不应影响主循环 + pass + else: + # 队列空:所有触发器暂无任务 (常规战打满 / 只等远征定时) → 挂机等待 + time.sleep(self._idle_sleep) + + _log.info('[Scheduler] 收到停止信号, 调度结束') + self._print_summary() + + def _enqueue(self, task: FightTask) -> None: + """按 priority 插入队列 (数值小先出;同 priority FIFO)。""" + idx = len(self._queue) + for i, existing in enumerate(self._queue): + if existing.priority > task.priority: + idx = i + break + self._queue.insert(idx, task) + _log.debug( + '[Scheduler] 入队: {} (prio={}, 队列长度={})', + task.name, + task.priority, + len(self._queue), + ) + + def _dequeue(self) -> FightTask | None: + """取出队首任务 (priority 最小者)。""" + if not self._queue: + return None + return self._queue.pop(0) + + def _check_daily_reset(self) -> None: + """检测跨日 (0 点) → 通知所有触发器 reset。 + + 游戏每日 0 点刷新战役次数、演习时段、掉落上限。 + 大部分由游戏自身重置 (脚本每次读画面值);脚本只需 reset 自身状态 + (战役 _exhausted、常规战完成计数、ctx 每日计数器)。 + """ + today = date.today() # noqa: DTZ011 # 本地墙上时钟 (跨日检测) + if today == self._last_date: + return + _log.info( + '[Scheduler] 检测到跨日 ({} → {}), 重置触发器', + self._last_date, + today, + ) + for trigger in self._triggers: + try: + trigger.reset() + except Exception as exc: + _log.opt(exception=True).warning( + '[Scheduler] 触发器 {} reset 异常: {}', + trigger.name, + exc, + ) + # 清零 ctx 每日计数器 (掉落 / 快修累计) + self._ctx.dropped_ship_count = 0 + self._ctx.dropped_loot_count = 0 + self._ctx.quick_repair_used = 0 + self._last_date = today + # ── 远征检查 ── def _maybe_collect_expedition(self) -> None: diff --git a/autowsgr/scheduler/triggers.py b/autowsgr/scheduler/triggers.py new file mode 100644 index 00000000..7fb9a3bb --- /dev/null +++ b/autowsgr/scheduler/triggers.py @@ -0,0 +1,380 @@ +"""auto_daily 触发器 — 决定何时把何种任务插入调度队列。 + +四类触发器,对应四类日常任务:: + + ExpeditionTrigger (定时, prio 0) 远征: 到点收取+重派, 无限循环 + CampaignTrigger (条件, prio 5) 战役: 每次一场, BATTLE_TIMES_EXCEED 即停, 跨日 reset + ExerciseTrigger (条件, prio 10) 演习: 每次一场, 无可挑战对手即停, 跨时段 reset + NormalFightTrigger (条件, prio 100) 常规战: 空闲填充, 到掉落/次数上限停, 跨日 reset + +设计要点: + +- 每个触发器同一时刻最多产出一个 pending 任务 (``_idle`` 标志), 避免队列堆积; + 任务完成后经 ``on_done`` 回调翻回 ``_idle``。 +- 战役/演习是「返回标志驱动」的可耗尽触发器 (:class:`ExhaustibleTrigger`): + runner 返回特定 :class:`~autowsgr.types.ConditionFlag` → 标记 ``_exhausted``, + 不再产出, 直到 ``reset()`` (跨日/跨时段)。 +- 常规战不靠返回标志, 而是每次 ``should_fire`` 主动检查全局上限 (``ctx`` 每日计数器)。 +- 跨日由 :class:`~autowsgr.scheduler.scheduler.TaskScheduler._check_daily_reset` 调 + ``reset()``;演习跨时段在 ``should_fire`` 内自检。 +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from autowsgr.infra.logger import get_logger +from autowsgr.scheduler.scheduler import FightTask +from autowsgr.types import ConditionFlag + + +if TYPE_CHECKING: + from autowsgr.combat import CombatResult + from autowsgr.context import GameContext + +_log = get_logger('scheduler') + +# 任务工厂: 接收 ctx, 返回一个有 run() 方法的 runner +# GameContext 仅运行时在 TYPE_CHECKING 下导入, 此处用字符串前向引用 +TaskFactory = Callable[['GameContext'], object] + +# 视为「成功打完一场」的标志 (用于常规战计数累加) +_DONE_FLAGS = { + ConditionFlag.OPERATION_SUCCESS, + ConditionFlag.FIGHT_END, + ConditionFlag.SL, +} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 基类 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class Trigger: + """触发器基类。 + + Parameters + ---------- + priority: + 产出任务的优先级 (数值越小越先执行)。 + name: + 触发器名称 (日志用)。 + task_factory: + 接收 ctx、返回 runner (有 ``run()`` 方法) 的工厂。每次 ``should_fire`` 命中时 + 调用, 产出新 runner 包装进 :class:`FightTask`。常规战触发器不用它 (各 plan + 自带工厂), 可留空。 + """ + + def __init__( + self, + *, + priority: int, + name: str, + task_factory: TaskFactory | None = None, + ) -> None: + self.priority = priority + self.name = name + self._task_factory = task_factory + # 同一时刻最多一个 pending 任务, 避免队列堆积 + self._idle = True + + def should_fire(self, ctx: GameContext) -> FightTask | None: + """是否应产出任务。子类重写, 返回 FightTask 或 None。""" + raise NotImplementedError + + def reset(self) -> None: + """跨日/跨时段重置。子类按需重写。""" + self._idle = True + + def _build_task( + self, + ctx: GameContext, + on_done: Callable[[CombatResult], None] | None = None, + ) -> FightTask: + """用 ``task_factory`` 构造一个 FightTask。""" + if self._task_factory is None: + raise RuntimeError(f'触发器 {self.name} 未配置 task_factory') + runner = self._task_factory(ctx) + return FightTask( + runner=runner, + times=1, + priority=self.priority, + name=self.name, + on_done=on_done, + ) + + +class ExhaustibleTrigger(Trigger): + """返回标志驱动的可耗尽触发器 (战役/演习)。 + + runner 返回的 ``CombatResult.flag`` 若命中 ``exhaust_flags``, 则标记 + ``_exhausted``, 不再产出新任务, 直到 :meth:`reset` (跨日/跨时段)。 + """ + + #: 子类定义: 命中这些 flag 即视为本周期打满 + exhaust_flags: ClassVar[set[ConditionFlag]] = set() + + def __init__( + self, + *, + task_factory: TaskFactory, + priority: int, + name: str, + ) -> None: + super().__init__(priority=priority, name=name, task_factory=task_factory) + self._exhausted = False + + def should_fire(self, ctx: GameContext) -> FightTask | None: + if not self._idle or self._exhausted: + return None + self._idle = False + return self._build_task(ctx, on_done=self._on_done) + + def _on_done(self, result: CombatResult) -> None: + self._idle = True + if result.flag in self.exhaust_flags: + self._exhausted = True + _log.info( + '[Trigger] {} 本周期已打满 ({}), 停止产出', + self.name, + result.flag, + ) + + def reset(self) -> None: + self._exhausted = False + self._idle = True + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 定时触发 (远征) +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TimerTrigger(Trigger): + """定时触发器: 到间隔产出一个任务, 不耗尽 (远征用)。""" + + def __init__( + self, + *, + task_factory: TaskFactory, + priority: int, + name: str, + interval: float, + ) -> None: + super().__init__(priority=priority, name=name, task_factory=task_factory) + self._interval = interval + self._last_fire = 0.0 + + def should_fire(self, ctx: GameContext) -> FightTask | None: + if not self._idle: + return None + if time.monotonic() - self._last_fire < self._interval: + return None + self._idle = False + self._last_fire = time.monotonic() + return self._build_task(ctx, on_done=self._on_done) + + def _on_done(self, _result: CombatResult) -> None: + self._idle = True + + +class ExpeditionTrigger(TimerTrigger): + """远征触发器: 定时收取 + 重派 (无限循环, 永不耗尽)。""" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 条件触发 (战役 / 演习) +# ═══════════════════════════════════════════════════════════════════════════════ + + +class CampaignTrigger(ExhaustibleTrigger): + """战役触发器: ``BATTLE_TIMES_EXCEED`` 即停 (每日次数 8/12 自适应)。""" + + exhaust_flags: ClassVar[set[ConditionFlag]] = {ConditionFlag.BATTLE_TIMES_EXCEED} + + +class ExerciseTrigger(ExhaustibleTrigger): + """演习触发器: 无可挑战对手 (``SKIP_FIGHT``) 即停, 跨时段 (0/12/18) reset。 + + 每日 0/12/18 三个时段各刷新 5 次机会;跨时段时 ``_exhausted`` 清零。 + """ + + exhaust_flags: ClassVar[set[ConditionFlag]] = {ConditionFlag.SKIP_FIGHT} + + def __init__( + self, + *, + task_factory: TaskFactory, + priority: int, + name: str, + ) -> None: + super().__init__(task_factory=task_factory, priority=priority, name=name) + self._last_slot = self._current_slot() + + @staticmethod + def _current_slot() -> int: + """当前演习时段: 0 (0-12点) / 1 (12-18点) / 2 (18-24点)。""" + hour = time.localtime().tm_hour + if hour < 12: + return 0 + if hour < 18: + return 1 + return 2 + + def should_fire(self, ctx: GameContext) -> FightTask | None: + slot = self._current_slot() + if slot != self._last_slot: + _log.info( + '[Trigger] 演习跨时段 ({}→{}), 重置可挑战次数', + self._last_slot, + slot, + ) + self._exhausted = False + self._idle = True + self._last_slot = slot + return super().should_fire(ctx) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 条件触发 (常规战 — 空闲填充) +# ═══════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class NormalFightPlan: + """常规战单个 plan 的运行时状态。 + + Attributes + ---------- + factory: + 接收 ctx、返回该 plan 的 NormalFightRunner 的工厂。 + name: + plan 名称 (如 ``8-5AI-only1DD``), 用于日志。 + fleet_id: + 出击舰队编号。 + target: + 本周期目标出击次数。``None`` 表示无限 (空闲填充, 仅受全局上限 + ``stop_max_ship`` / ``stop_max_loot`` / ``quick_repair_limit`` 约束)。 + dev 的 ``DailyAutomationConfig.normal_fight_tasks`` 只给 plan 名、不给次数, + 所以默认 ``None``, 多个无限 plan 会轮询执行。 + completed: + 已成功完成次数 (运行时, 仅日志/有限 plan 的停止判断用)。 + """ + + factory: TaskFactory + name: str + fleet_id: int + target: int | None = None + completed: int = 0 + + +class NormalFightTrigger(Trigger): + """常规战触发器: 多 plan 列表 + 全局上限, 空闲填充 (priority 最低)。 + + 每次产出一个未达 ``target`` 的 plan 的任务;全部 plan 达 target, 或 ``ctx`` + 每日掉落/船/快修达上限, 则停止产出 (直到 :meth:`reset`)。 + + priority 设为最高数值 → 排队尾 → 只有没其他任务时才执行 (= 空闲填充)。 + """ + + def __init__( + self, + *, + priority: int, + name: str, + plans: list[NormalFightPlan], + stop_max_ship: bool = False, + ship_limit: int = 500, + stop_max_loot: bool = False, + loot_limit: int = 50, + quick_repair_limit: int | None = None, + ) -> None: + super().__init__(priority=priority, name=name) + self._plans = plans + self._stop_max_ship = stop_max_ship + self._ship_limit = ship_limit + self._stop_max_loot = stop_max_loot + self._loot_limit = loot_limit + self._quick_repair_limit = quick_repair_limit + self._current: NormalFightPlan | None = None + # 无限 plan (target=None) 的轮询游标 + self._round_robin = 0 + + def should_fire(self, ctx: GameContext) -> FightTask | None: + if not self._idle: + return None + if self._is_exhausted(ctx): + return None + plan = self._pick_plan() + if plan is None: + return None + self._idle = False + self._current = plan + runner = plan.factory(ctx) + return FightTask( + runner=runner, + times=1, + priority=self.priority, + name=f'{self.name}/{plan.name}', + on_done=self._on_done, + ) + + def _has_plan(self) -> bool: + """是否有可产出的 plan (无副作用, 供耗尽判断用)。""" + return any(plan.target is None or plan.completed < plan.target for plan in self._plans) + + def _pick_plan(self) -> NormalFightPlan | None: + """选下一个 plan: 优先未完成的有 target 的; 再轮询无限的。""" + for plan in self._plans: + if plan.target is not None and plan.completed < plan.target: + return plan + unlimited = [p for p in self._plans if p.target is None] + if unlimited: + plan = unlimited[self._round_robin % len(unlimited)] + self._round_robin += 1 + return plan + return None + + def _is_exhausted(self, ctx: GameContext) -> bool: + """是否本周期常规战已全部完成 / 达上限。""" + if self._stop_max_ship and ctx.dropped_ship_count >= self._ship_limit: + return True + if self._stop_max_loot and ctx.dropped_loot_count >= self._loot_limit: + return True + if ( + self._quick_repair_limit is not None + and ctx.quick_repair_used >= self._quick_repair_limit + ): + return True + return not self._has_plan() + + def _on_done(self, result: CombatResult) -> None: + self._idle = True + if self._current is None: + return + # 仅成功打完一场才计数 (对齐 classic: SUCCESS/SL 才算) + if result.flag in _DONE_FLAGS: + self._current.completed += 1 + target = self._current.target + progress = ( + f'{self._current.completed}/{target}' + if target is not None + else str(self._current.completed) + ) + _log.info( + '[Trigger] {} {} 出击 {}', + self.name, + self._current.name, + progress, + ) + + def reset(self) -> None: + for plan in self._plans: + plan.completed = 0 + self._idle = True + self._current = None + self._round_robin = 0 diff --git a/autowsgr/types.py b/autowsgr/types.py index 79a4a84d..04591e01 100644 --- a/autowsgr/types.py +++ b/autowsgr/types.py @@ -406,6 +406,9 @@ class ConditionFlag(StrEnum): """跳过战斗""" SL = 'SL' """需要 / 进行了 SL 操作""" + ACTION_FAILED = 'action failed' + """操作失败 (子任务异常, 调度器捕获后结束本子任务; 不属于成功/耗尽标志, + 故触发器 on_done 不会计入完成次数、不会误判耗尽)""" class PageName(StrEnum): diff --git a/autowsgr/ui/bath_page/page.py b/autowsgr/ui/bath_page/page.py index a3d15767..fe3b3b32 100644 --- a/autowsgr/ui/bath_page/page.py +++ b/autowsgr/ui/bath_page/page.py @@ -335,6 +335,49 @@ def repair_ship(self, ship_name: str) -> int: screen=self._ctrl.screenshot(), ) + def repair_longest(self, blacklist: set[str] | None = None) -> int: + """在选择修理 overlay 中修理修理时间最长的非黑名单舰船。 + + 逐页扫描 (最多 10 页), 在第一个含非黑名单候选的页内选最长者点击。 + 游戏默认按修理时间降序列出, 故首页最长 ≈ 全局最长。 + + Parameters + ---------- + blacklist: + 不修理的舰船名集合 (中文全名)。命中则跳过。 + + Returns + ------- + int + 修理秒数 (``>0`` 成功); ``-1`` 无可修候选 (空或全被排除); + ``-2`` 浴场已满 (点击后 overlay 未关闭)。 + """ + blocked = blacklist or set() + target: RepairShipInfo | None = None + for _ in range(10): + candidates = [ + s for s in self.recognize_repair_ships() if s.name and s.name not in blocked + ] + if candidates: + target = max(candidates, key=lambda s: s.repair_seconds) + break + self._swipe_left() + + if target is None: + _log.info('[UI] 选择修理: 无可修理舰船 (或均被黑名单排除)') + return -1 + + _log.info( + '[UI] 选择修理 → 修理最长: {} ({})', + target.name, + target.repair_time, + ) + self._ctrl.click(*target.position) + if self._try_wait_overlay_close(): + return target.repair_seconds + _log.warning('[UI] 浴场已满, 修理 {} 失败', target.name) + return -2 + def recognize_repair_ships(self) -> list[RepairShipInfo]: """识别选择修理 overlay 中当前可见的待修理舰船。 diff --git a/examples/auto_daily.py b/examples/auto_daily.py new file mode 100644 index 00000000..a366e652 --- /dev/null +++ b/examples/auto_daily.py @@ -0,0 +1,117 @@ +"""auto_daily — 全天挂机 (战役 + 演习 + 远征 + 常规战)。 + +读取配置文件的 ``daily_automation`` 段, 用「触发器 + 优先级队列」调度器持续挂机, +支持跨日自动重置 (0 点刷新战役次数 / 演习时段 / 当日掉落上限)。 + +优先级: 远征(0) < 战役(5) < 演习(10) < 常规战(100, 空闲填充)。 + +用法:: + + # 默认查找当前目录下的 user_settings.yaml / usersettings.yaml + python examples/auto_daily.py + + # 显式指定配置 (推荐, 用户常用文件名带下划线) + python examples/auto_daily.py --config D:/Games/autowsgr/old/user_settings.yaml + +停止: + - 第一次 Ctrl+C → 设置停止信号, 等当前战斗结束后优雅退出。 + - 第二次 Ctrl+C → 强制退出 (KeyboardInterrupt)。 +""" + +from __future__ import annotations + +import argparse +import signal +import sys +from pathlib import Path + +from autowsgr.scheduler import TaskScheduler, build_daily_plan, launch + + +# 默认查找的配置文件名 (兼容带/不带下划线两种写法) +_DEFAULT_CONFIG_CANDIDATES = ( + 'user_settings.yaml', + 'usersettings.yaml', +) + + +def _resolve_config(path: str | None) -> str | None: + """解析配置文件路径; 未指定时按候选名在当前目录查找。""" + if path: + p = Path(path) + if not p.is_file(): + raise FileNotFoundError(f'配置文件不存在: {path}') + return str(p) + for name in _DEFAULT_CONFIG_CANDIDATES: + if Path(name).is_file(): + return name + return None # 交由 launch() 自动检测或使用默认值 + + +def main() -> int: + parser = argparse.ArgumentParser( + description='auto_daily 全天挂机 (战役+演习+远征+常规战)', + ) + parser.add_argument( + '--config', + '-c', + help='配置文件路径 (YAML); 未指定则查找 user_settings.yaml / usersettings.yaml', + ) + parser.add_argument( + '--expedition-interval', + type=float, + default=600.0, + help='远征轮询间隔 (秒), 默认 600', + ) + parser.add_argument( + '--idle-sleep', + type=float, + default=5.0, + help='队列空闲时的挂机轮询间隔 (秒), 默认 5', + ) + args = parser.parse_args() + + config_path = _resolve_config(args.config) + if config_path is None: + print( + '[auto_daily] 未找到配置文件, 将使用默认值 ' + '(daily_automation 可能为空 → 不挂机)。' + '可用 --config 指定。', + file=sys.stderr, + ) + + # 1. 启动: 加载配置 → 连接模拟器 → 启动游戏 → 返回就绪 ctx + ctx = launch(config_path) + + if ctx.config.daily_automation is None: + print( + '[auto_daily] 配置中无 daily_automation 段, 退出。' + '请在配置文件中添加 daily_automation 设置。', + file=sys.stderr, + ) + return 1 + + # 2. Ctrl+C → 优雅停止 (第一次) / 强制退出 (第二次) + def _on_stop(signum: int, frame: object) -> None: # noqa: ARG001 + if ctx.stop_event.is_set(): + print('\n[auto_daily] 再次收到停止信号, 强制退出。', file=sys.stderr) + raise KeyboardInterrupt + print('\n[auto_daily] 收到停止信号, 等当前任务结束后退出...', file=sys.stderr) + ctx.stop_event.set() + + signal.signal(signal.SIGINT, _on_stop) + if sys.platform != 'win32': + signal.signal(signal.SIGTERM, _on_stop) + + # 3. 构建日常计划: 把 daily_automation 翻译成触发器 + # expedition_interval=0: 关闭旧的顺序远征检查, 交给远征触发器 + scheduler = TaskScheduler(ctx, expedition_interval=0, idle_sleep=args.idle_sleep) + build_daily_plan(scheduler, ctx, expedition_interval=args.expedition_interval) + + # 4. 触发器调度主循环 (持续挂机, 直到 stop_event) + scheduler.run_daily() + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From 7266cd0517b3bee2fceae6bcefb0efac100a6db1 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:37:57 +0800 Subject: [PATCH 05/14] =?UTF-8?q?chore(examples):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E4=B8=8E=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=BA=E6=96=B0=E7=89=88=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usersettings.full.yaml / usersettings.yaml 同步 operation_delay_min/max 等新字段, 移除 classic 已迁移项。 Co-Authored-By: Claude --- examples/usersettings.full.yaml | 17 +++-------------- usersettings.yaml | 19 ++++--------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/examples/usersettings.full.yaml b/examples/usersettings.full.yaml index dfc6c32d..0ae21d36 100644 --- a/examples/usersettings.full.yaml +++ b/examples/usersettings.full.yaml @@ -22,8 +22,6 @@ emulator: # ═══════════════════════════════════════════ account: game_app: 官服 # 官服 / 小米服 / 应用宝服 - account: null # 游戏账号 (用于自动登录) - password: null # 游戏密码 # ═══════════════════════════════════════════ # OCR 引擎 @@ -46,14 +44,7 @@ log: # dir: null # 日志保存路径; null = 自动按日期生成 # 细粒度显示开关 - show_map_node: false - show_android_input: true - show_enemy_rules: true - show_fight_stage: true - show_chapter_info: true - show_match_fight_stage: true show_decisive_battle_info: true - show_ocr_info: true show_emulator_debug: true # 是否输出 emulator 通道 DEBUG (click/swipe 等操作) show_ui_debug: true # 是否输出 ui 通道 DEBUG (页面识别/等待轮询) show_vision_debug: true # 是否输出 vision 通道 DEBUG (模板匹配规则结果) @@ -69,12 +60,10 @@ log: # ═══════════════════════════════════════════ # 脚本行为 # ═══════════════════════════════════════════ -delay: 1.5 # 延迟时间基本单位 (秒) -check_page: true # 启动时是否检查游戏页面 dock_full_destroy: true # 船坞满时自动清空 repair_manually: false # 是否手动修理 -bathroom_feature_count: 1 # 浴室装饰数 (1-3) -bathroom_count: 2 # 修理位置总数 (≤12) +bathroom_feature_count: 1 # 预留: 智能浴场装饰调度 (暂未启用) +bathroom_count: 2 # 浴室修理槽位数 (auto_bath_repair 空位调度用) # ── 解装设置 ── destroy_ship_work_mode: 不启用 # 不启用 / 黑名单 / 白名单 @@ -85,7 +74,6 @@ remove_equipment_mode: true # 拆解时默认卸下装备 # ── 数据路径 ── plan_root: null # 自定义计划文件目录 -ship_name_file: null # 自定义舰船名文件 # ═══════════════════════════════════════════ # 日常自动化 (不需要可删除整个 daily_automation 块) @@ -95,6 +83,7 @@ daily_automation: auto_expedition: true # 自动重复远征 auto_gain_bonus: true # 任务完成时自动点击 auto_bath_repair: true # 空闲时自动澡堂修理 + bath_repair_blacklist: [] # 不修理的舰船名 (auto_bath_repair 黑名单) auto_set_support: false # 自动开启战役支援 # 战役 diff --git a/usersettings.yaml b/usersettings.yaml index 3e818e2b..85999a78 100644 --- a/usersettings.yaml +++ b/usersettings.yaml @@ -22,8 +22,6 @@ emulator: # ═══════════════════════════════════════════ account: game_app: 官服 # 官服 / 小米服 / 应用宝服 - account: null # 游戏账号 (用于自动登录) - password: null # 游戏密码 # ═══════════════════════════════════════════ # OCR 引擎 @@ -46,14 +44,7 @@ log: # dir: null # 日志保存路径; null = 自动按日期生成 # 细粒度显示开关 - show_map_node: true - show_android_input: false - show_enemy_rules: false - show_fight_stage: false - show_chapter_info: false - show_match_fight_stage: false show_decisive_battle_info: true - show_ocr_info: false show_emulator_debug: false # 是否输出 emulator 通道 DEBUG (click/swipe 等操作) show_ui_debug: false # 是否输出 ui 通道 DEBUG (页面识别/等待轮询) show_vision_debug: false # 是否输出 vision 通道 DEBUG (模板匹配规则结果) @@ -69,12 +60,10 @@ log: # ═══════════════════════════════════════════ # 脚本行为 # ═══════════════════════════════════════════ -delay: 1.5 # 延迟时间基本单位 (秒) -check_page: true # 启动时是否检查游戏页面 dock_full_destroy: true # 船坞满时自动清空 repair_manually: false # 是否手动修理 -bathroom_feature_count: 1 # 浴室装饰数 (1-3) -bathroom_count: 2 # 修理位置总数 (≤12) +bathroom_feature_count: 1 # 预留: 智能浴场装饰调度 (暂未启用) +bathroom_count: 2 # 浴室修理槽位数 (auto_bath_repair 空位调度用) # ── 解装设置 ── destroy_ship_work_mode: 不启用 # 不启用 / 黑名单 / 白名单 @@ -84,8 +73,7 @@ destroy_ship_types: [] # 指定舰种列表, 参照 ShipType 枚举 remove_equipment_mode: true # 拆解时默认卸下装备 # ── 数据路径 ── -plan_root: null # 自定义计划文件目录 -ship_name_file: null # 自定义舰船名文件 +plan_root: null # 自定义计划根目录; 下置 normal_fight/.yaml, 同名优先于包内默认 # ═══════════════════════════════════════════ # 日常自动化 (不需要可删除整个 daily_automation 块) @@ -95,6 +83,7 @@ daily_automation: auto_expedition: true # 自动重复远征 auto_gain_bonus: true # 任务完成时自动点击 auto_bath_repair: true # 空闲时自动澡堂修理 + bath_repair_blacklist: [] # 不修理的舰船名 (auto_bath_repair 黑名单) auto_set_support: false # 自动开启战役支援 # 战役 From acc518c2b6141a04b7973349ad777d683fa8c082 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:37:59 +0800 Subject: [PATCH 06/14] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=A8=20file=5Futils?= =?UTF-8?q?/destroy/bath/scheduler=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 / 补全 testing/{infra/test_file_utils, ops/test_destroy_unit, ops/test_scheduler_unit, context/test_bathroom} 单元测试。 Co-Authored-By: Claude --- testing/context/__init__.py | 0 testing/context/test_bathroom.py | 97 +++++++++++++ testing/infra/test_file_utils.py | 62 ++++++++ testing/ops/test_destroy_unit.py | 101 +++++++++++++ testing/ops/test_scheduler_unit.py | 220 +++++++++++++++++++++++++++++ 5 files changed, 480 insertions(+) create mode 100644 testing/context/__init__.py create mode 100644 testing/context/test_bathroom.py create mode 100644 testing/ops/test_destroy_unit.py create mode 100644 testing/ops/test_scheduler_unit.py diff --git a/testing/context/__init__.py b/testing/context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testing/context/test_bathroom.py b/testing/context/test_bathroom.py new file mode 100644 index 00000000..e0586db1 --- /dev/null +++ b/testing/context/test_bathroom.py @@ -0,0 +1,97 @@ +"""BathRoom 浴室槽位状态机单元测试 (无设备)。 + +验证空位判定 / 占用 / 释放 / mark_unknown 的状态转移。 +通过 monkeypatch 控制 ``time.time()``, 实现确定性断言。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from autowsgr.context.bathroom import BathRoom + + +if TYPE_CHECKING: + import pytest + + +def test_unknown_state_is_available(): + """初始 ``available_time=None`` 视为有空位 (允许尝试派修以重建状态)。""" + bath = BathRoom() + assert bath.available_time is None + assert bath.is_available() is True + assert bath.get_waiting_time() == 0.0 + + +def test_occupy_fills_then_blocks(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: 1000.0) + bath = BathRoom(slot_count=2) + + assert bath.is_available() is True # 两槽全空 + bath.occupy(500) # 槽 0 → 释放于 1500 + assert bath.is_available() is True # 槽 1 仍空 + bath.occupy(500) # 槽 1 → 释放于 1500 + assert bath.is_available() is False # 两槽全忙 + assert bath.get_waiting_time() == 500.0 + + +def test_slot_releases_over_time(monkeypatch: pytest.MonkeyPatch): + """释放时间到期后, 槽位自动回收为空闲。""" + t_box = [1000.0] + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: t_box[0]) + + bath = BathRoom(slot_count=1) + bath.occupy(300) # 释放于 1300 + assert bath.is_available() is False + assert bath.get_waiting_time() == 300.0 + + t_box[0] = 1300.0 # 到点 + assert bath.is_available() is True + assert bath.get_waiting_time() == 0.0 + + +def test_occupy_non_positive_is_noop(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: 1000.0) + bath = BathRoom(slot_count=1) + + bath.occupy(0) + bath.occupy(-5) + assert bath.is_available() is True # 未占用 + + +def test_mark_unknown_forces_retry(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: 1000.0) + bath = BathRoom(slot_count=1) + bath.occupy(999) + assert bath.is_available() is False + + bath.mark_unknown() + assert bath.available_time is None + assert bath.is_available() is True # 下次触发可重试 + + +def test_slot_count_clamped_to_minimum(monkeypatch: pytest.MonkeyPatch): + """``slot_count <= 0`` 初始化时夹到 1, 避免空槽列表。""" + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: 1000.0) + bath = BathRoom(slot_count=0) + bath.occupy(100) # 触发 _ensure_initialized + assert bath.available_time is not None + assert len(bath.available_time) == 1 + + +def test_occupy_reuses_earliest_freed_slot(monkeypatch: pytest.MonkeyPatch): + """有多个忙槽时, 优先占用最早释放的那个。""" + t_box = [1000.0] + monkeypatch.setattr('autowsgr.context.bathroom.time.time', lambda: t_box[0]) + bath = BathRoom(slot_count=2) + bath.occupy(500) # 槽 0 → 1500 + bath.occupy(800) # 槽 1 → 1800 + assert bath.get_waiting_time() == 500.0 # 取最早 + + t_box[0] = 1500.0 # 仅槽 0 到期 + assert bath.is_available() is True + bath.occupy(100) # 复用槽 0 → 1600; 槽 1 仍 1800 + + # 复用后两槽皆忙 (1600 / 1800), 最近释放还需 100s + assert bath.is_available() is False + assert bath.get_waiting_time() == 100.0 diff --git a/testing/infra/test_file_utils.py b/testing/infra/test_file_utils.py index e3a8de88..747b19f9 100644 --- a/testing/infra/test_file_utils.py +++ b/testing/infra/test_file_utils.py @@ -6,6 +6,7 @@ import pytest from autowsgr.infra import load_yaml, merge_dicts, save_yaml +from autowsgr.infra.file_utils import resolve_plan_path class TestLoadYaml: @@ -95,3 +96,64 @@ def test_does_not_mutate_originals(self): merge_dicts(base, override) assert base == {'a': {'x': 1}} assert override == {'a': {'y': 2}} + + +class TestResolvePlanPath: + """测试 resolve_plan_path —— 含 plan_root 优先 / 回退语义。 + + 对齐 classic ``plan_root``: 用户自定义目录同名文件优先于包内默认, + 未命中则回退到 ``autowsgr/data/plan/{category}/``。 + """ + + def test_plan_root_overrides_package_default(self, tmp_path: Path): + """plan_root 同名文件优先于包内默认。""" + root = tmp_path / 'my_plans' + (root / 'normal_fight').mkdir(parents=True) + user_plan = root / 'normal_fight' / '1-1.yaml' + user_plan.write_text('# user override\n', encoding='utf-8') + + resolved = resolve_plan_path('1-1', plan_root=root) + assert resolved == user_plan.resolve() + + def test_plan_root_without_yaml_suffix(self, tmp_path: Path): + """plan_root 查找支持省略 .yaml 后缀。""" + root = tmp_path / 'my_plans' + (root / 'normal_fight').mkdir(parents=True) + (root / 'normal_fight' / 'custom.yaml').write_text('k: v\n', encoding='utf-8') + + resolved = resolve_plan_path('custom', plan_root=root) + assert resolved.name == 'custom.yaml' + assert resolved.parent == (root / 'normal_fight').resolve() + + def test_fallback_to_package_default(self, tmp_path: Path): + """plan_root 未命中时回退到包内默认目录。""" + from autowsgr.infra.file_utils import _get_package_data_dir + + root = tmp_path / 'empty_plans' + (root / 'normal_fight').mkdir(parents=True) + + resolved = resolve_plan_path('1-1', plan_root=root) + expected_dir = (_get_package_data_dir() / 'plan' / 'normal_fight').resolve() + assert resolved.parent == expected_dir + assert resolved.name == '1-1.yaml' + + def test_no_plan_root_falls_back_to_package(self): + """plan_root=None 时仅查包内默认 (旧行为不变)。""" + from autowsgr.infra.file_utils import _get_package_data_dir + + resolved = resolve_plan_path('1-1') + expected_dir = (_get_package_data_dir() / 'plan' / 'normal_fight').resolve() + assert resolved.parent == expected_dir + + def test_not_found_lists_searched_paths(self, tmp_path: Path): + """都不存在时抛 FileNotFoundError 并列出所有搜索过的路径。""" + root = tmp_path / 'my_plans' + (root / 'normal_fight').mkdir(parents=True) + + with pytest.raises(FileNotFoundError) as exc_info: + resolve_plan_path('绝对不存在的策略', plan_root=root) + + msg = str(exc_info.value) + assert '绝对不存在的策略' in msg + # 错误信息应同时包含 plan_root 候选与包数据目录候选 + assert str(root / 'normal_fight') in msg diff --git a/testing/ops/test_destroy_unit.py b/testing/ops/test_destroy_unit.py new file mode 100644 index 00000000..b486d490 --- /dev/null +++ b/testing/ops/test_destroy_unit.py @@ -0,0 +1,101 @@ +"""destroy_ships_auto 模式调度单元测试 (无设备)。 + +验证 disable / include / exclude 三种工作模式 + remove_equipment 的派发逻辑。 +通过 monkeypatch 拦截 ``destroy_ships``, 不触发真实导航 / IO。 +""" + +from __future__ import annotations + +import pytest + +from autowsgr.ops import destroy as destroy_module +from autowsgr.types import DestroyShipWorkMode, ShipType + + +class _FakeConfig: + """最小 config 替身, 仅暴露 destroy 相关字段。""" + + def __init__( + self, + mode: DestroyShipWorkMode, + types: list[ShipType] | None = None, + remove_eq: bool = True, + ) -> None: + self.destroy_ship_work_mode = mode + self.destroy_ship_types = types or [] + self.remove_equipment_mode = remove_eq + + +class _FakeCtx: + def __init__(self, cfg: _FakeConfig) -> None: + self.config = cfg + + +@pytest.fixture +def recorded(monkeypatch: pytest.MonkeyPatch) -> list[dict]: + """拦截 destroy_ships, 记录每次调用的参数。""" + calls: list[dict] = [] + + def _fake_destroy_ships( + _ctx: object, + *, + ship_types: list[ShipType] | None = None, + remove_equipment: bool = True, + ) -> None: + calls.append({'ship_types': ship_types, 'remove_equipment': remove_equipment}) + + monkeypatch.setattr(destroy_module, 'destroy_ships', _fake_destroy_ships) + return calls + + +def test_disable_uses_quick_route_no_filter(recorded: list[dict]): + """disable (不启用舰种分类): 不过滤, 走快速拆解路线, 解装全部。""" + from autowsgr.ops.destroy import destroy_ships_auto + + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.disable)) + assert destroy_ships_auto(ctx) is True + assert recorded == [{'ship_types': None, 'remove_equipment': True}] + + +def test_include_passes_listed_types(recorded: list[dict]): + from autowsgr.ops.destroy import destroy_ships_auto + + types = [ShipType.DD, ShipType.CL] + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.include, types=types, remove_eq=False)) + assert destroy_ships_auto(ctx) is True + assert recorded == [{'ship_types': types, 'remove_equipment': False}] + + +def test_include_empty_types_means_all(recorded: list[dict]): + """include + 空舰种列表 → ship_types=None (不过滤, 全量解装)。""" + from autowsgr.ops.destroy import destroy_ships_auto + + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.include)) + assert destroy_ships_auto(ctx) is True + assert recorded == [{'ship_types': None, 'remove_equipment': True}] + + +def test_exclude_computes_complement(recorded: list[dict]): + """exclude (白名单): 解装除指定舰种外的所有非 Other 舰种。""" + from autowsgr.ops.destroy import destroy_ships_auto + + protected = [ShipType.CV] + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.exclude, types=protected)) + assert destroy_ships_auto(ctx) is True + + call = recorded[0] + expected = {t for t in ShipType if t is not ShipType.Other and t not in set(protected)} + assert set(call['ship_types']) == expected + assert ShipType.CV not in call['ship_types'] + assert ShipType.Other not in call['ship_types'] + assert call['remove_equipment'] is True + + +def test_exclude_all_types_returns_false(recorded: list[dict]): + """白名单覆盖全部非 Other 舰种 → 无可解装对象 → 返回 False。""" + from autowsgr.ops.destroy import destroy_ships_auto + + all_real = [t for t in ShipType if t is not ShipType.Other] + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.exclude, types=all_real)) + assert destroy_ships_auto(ctx) is False + assert recorded == [] diff --git a/testing/ops/test_scheduler_unit.py b/testing/ops/test_scheduler_unit.py new file mode 100644 index 00000000..dead16a3 --- /dev/null +++ b/testing/ops/test_scheduler_unit.py @@ -0,0 +1,220 @@ +"""TaskScheduler runner 适配单元测试 (无设备)。 + +回归: CampaignRunner / ExerciseRunner 的 ``run()`` 返回 ``list[CombatResult]``, +调度器必须经 :class:`BatchRunnerAdapter` 适配为单个结果, 否则 ``on_done`` / +``result.flag`` 会触发 ``'list' object has no attribute 'flag'`` 崩溃。 +""" + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +from autowsgr.combat import CombatResult +from autowsgr.scheduler.scheduler import BatchRunnerAdapter, FightTask, TaskScheduler +from autowsgr.types import ConditionFlag + + +if TYPE_CHECKING: + import pytest + + +# ── 假 runner ── + + +class _ListRunner: + """模拟 CampaignRunner: run() 返回 list[CombatResult]。""" + + def __init__(self, results: list[CombatResult]) -> None: + self._results = results + + def run(self) -> list[CombatResult]: + return list(self._results) + + +class _SingleRunner: + """模拟 NormalFightRunner: run() 返回单个 CombatResult。""" + + def __init__(self, result: CombatResult) -> None: + self._result = result + + def run(self) -> CombatResult: + return self._result + + +# ── BatchRunnerAdapter 行为 ── + + +def test_batch_adapter_list_takes_last(): + r1 = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + r2 = CombatResult(flag=ConditionFlag.BATTLE_TIMES_EXCEED) + assert BatchRunnerAdapter(_ListRunner([r1, r2])).run() is r2 + + +def test_batch_adapter_single_passthrough(): + r = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + assert BatchRunnerAdapter(_SingleRunner(r)).run() is r + + +def test_batch_adapter_empty_list_defaults_success(): + out = BatchRunnerAdapter(_ListRunner([])).run() + assert out.flag == ConditionFlag.OPERATION_SUCCESS + + +# ── _run_task 端到端 (list runner 不再崩溃) ── + + +class _FakeCtx: + """最小 ctx 替身: 仅暴露 _run_task 访问的成员。""" + + def __init__(self) -> None: + self.active_fight_tasks = 0 + self.stop_event = threading.Event() + + +def test_run_task_handles_list_runner(monkeypatch: pytest.MonkeyPatch): + """返回 list 的 runner 经调度器后, on_done 收到单个 CombatResult (回归崩溃)。""" + ctx = _FakeCtx() + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + monkeypatch.setattr(sched, '_maybe_collect_expedition', lambda: None) + + received: list[CombatResult] = [] + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + task = FightTask( + runner=_ListRunner([result]), + times=1, + on_done=received.append, + ) + + sched._run_task(task) # 不应抛 AttributeError + + assert received == [result] # on_done 收到单个, 不是 list + assert task.results == [result] + assert task.completed == 1 + + +def test_run_task_list_runner_exceed_flag(monkeypatch: pytest.MonkeyPatch): + """list runner 最后一场为 BATTLE_TIMES_EXCEED 时, 该 flag 正确传递给 on_done。""" + ctx = _FakeCtx() + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + monkeypatch.setattr(sched, '_maybe_collect_expedition', lambda: None) + + seen: list[ConditionFlag] = [] + ok = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + exceed = CombatResult(flag=ConditionFlag.BATTLE_TIMES_EXCEED) + task = FightTask( + runner=_ListRunner([ok, exceed]), + times=1, + on_done=lambda r: seen.append(r.flag), + ) + + sched._run_task(task) + + assert seen == [ConditionFlag.BATTLE_TIMES_EXCEED] # 取最后一场 + + +def test_run_task_single_runner_still_works(monkeypatch: pytest.MonkeyPatch): + """单个 CombatResult runner 经适配后仍正确 (passthrough 不破坏)。""" + ctx = _FakeCtx() + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + monkeypatch.setattr(sched, '_maybe_collect_expedition', lambda: None) + + received: list[CombatResult] = [] + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + task = FightTask(runner=_SingleRunner(result), times=1, on_done=received.append) + + sched._run_task(task) + + assert received == [result] + assert task.results == [result] + + +# ── 浴室修理优先级 (空闲修船: 所有战斗完成后才执行) ── + + +def test_bath_repair_priority_after_all_combat(): + """浴室修理优先级 > 所有战斗任务, 还原 classic '所有战斗 (含常规战) 完成后才修船'。""" + from autowsgr.scheduler.daily_plan import ( + PRIO_BATH_REPAIR, + PRIO_BONUS, + PRIO_CAMPAIGN, + PRIO_EXERCISE, + PRIO_EXPEDITION, + PRIO_NORMAL_FIGHT, + ) + + assert PRIO_BATH_REPAIR > PRIO_NORMAL_FIGHT + assert PRIO_BATH_REPAIR > PRIO_EXERCISE + assert PRIO_BATH_REPAIR > PRIO_CAMPAIGN + assert PRIO_BATH_REPAIR > PRIO_BONUS + assert PRIO_BATH_REPAIR > PRIO_EXPEDITION + + +def test_bath_repair_queues_behind_normal_fight(): + """同一队列里浴室修理 (prio 200) 永远排在常规战 (prio 100) 之后出队。""" + ctx = _FakeCtx() + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + bath = FightTask(runner=object(), priority=200, name='浴室修理') + normal = FightTask(runner=object(), priority=100, name='常规战') + + # 无论入队顺序, 常规战 (100) 先出队 → 浴室修理等常规战打完才轮到 + sched._enqueue(bath) + sched._enqueue(normal) + assert sched._dequeue().name == '常规战' + assert sched._dequeue().name == '浴室修理' + + +# ── 无限常规战饿死浴室修理的启动告警 ── + + +def test_starvation_warned_when_infinite_normal_fight_no_limits(): + """无限常规战 (times=None) + 停止上限全关 + 启用浴室修理 → 应告警。""" + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.scheduler.daily_plan import _bath_repair_starved_by_normal_fight + + cfg = DailyAutomationConfig( + auto_bath_repair=True, + auto_normal_fight=True, + normal_fight_tasks=[{'name': 'x'}], # times 默认 None + ) + assert _bath_repair_starved_by_normal_fight(cfg) is True + + +def test_starvation_not_warned_when_times_set(): + """常规战设了 times (有限) → 不会饿死浴室修理, 不告警。""" + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.scheduler.daily_plan import _bath_repair_starved_by_normal_fight + + cfg = DailyAutomationConfig( + auto_bath_repair=True, + auto_normal_fight=True, + normal_fight_tasks=[{'name': 'x', 'times': 10}], + ) + assert _bath_repair_starved_by_normal_fight(cfg) is False + + +def test_starvation_not_warned_when_stop_limit_enabled(): + """开启任一停止上限 → 常规战终会耗尽让位, 不告警。""" + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.scheduler.daily_plan import _bath_repair_starved_by_normal_fight + + cfg = DailyAutomationConfig( + auto_bath_repair=True, + auto_normal_fight=True, + normal_fight_tasks=[{'name': 'x'}], + stop_max_ship=True, + ) + assert _bath_repair_starved_by_normal_fight(cfg) is False + + +def test_starvation_not_warned_when_bath_repair_off(): + """未启用浴室修理 → 无所谓饿死, 不告警。""" + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.scheduler.daily_plan import _bath_repair_starved_by_normal_fight + + cfg = DailyAutomationConfig( + auto_bath_repair=False, + auto_normal_fight=True, + normal_fight_tasks=[{'name': 'x'}], + ) + assert _bath_repair_starved_by_normal_fight(cfg) is False From 08fee1635c4eb380e7e70424fcf1f3fe13c9aa11 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 11:54:46 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix(test):=20operation=5Fdelay=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8F=90=E4=BE=9B=20emulator(serial,path)=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20Linux=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_operation_delay_field_sets_globals 之前裸构造 UserConfig(...) 无 emulator: - Linux/WSL CI: OSType.auto() 走 linux 分支强制要求 emulator.serial → ValidationError - 改 os_type=windows 又在非 Windows 上触发 auto_emulator_path → import winreg 失败 给定 serial+path 的 emulator 既满足 linux 分支, 又让 windows 分支跳过 auto_emulator_path (不 import winreg), 两端皆过。本用例只验证 operation_delay 字段 → 模块全局。 Co-Authored-By: Claude --- testing/infra/test_config.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/testing/infra/test_config.py b/testing/infra/test_config.py index f07391e2..af2e84c9 100644 --- a/testing/infra/test_config.py +++ b/testing/infra/test_config.py @@ -330,7 +330,14 @@ def test_detect_migrate_consistency(self): def test_operation_delay_field_sets_globals(self): from autowsgr.infra import config - cfg = UserConfig(operation_delay_min=1.0, operation_delay_max=2.0) + # 给定 serial+path 的 emulator: 既满足 linux/WSL 分支的强制要求, + # 又让 windows 分支跳过 auto_emulator_path (避免在非 Windows 上 import winreg)。 + # 本用例只验证 operation_delay 字段 → 模块全局, 与模拟器无关。 + cfg = UserConfig( + emulator=EmulatorConfig(serial='emulator-5554', path='/fake/dnplayer.exe'), + operation_delay_min=1.0, + operation_delay_max=2.0, + ) assert cfg.operation_delay_min == 1.0 assert cfg.operation_delay_max == 2.0 assert config.OPERATION_DELAY_MIN == 1.0 From fd0254bc4d7a14fc77eb3fe35885314022726cd4 Mon Sep 17 00:00:00 2001 From: KUAI Date: Mon, 27 Jul 2026 15:09:05 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E9=80=89=E8=88=B9=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E9=A2=9D=E5=A4=96=E5=BB=B6=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/ui/choose_ship_page.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index 46a6bee0..1c6565c7 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -171,6 +171,8 @@ def input_ship_name(self, name: str) -> None: """ _log.debug("[UI] 选船 → 输入舰船名 '{}'", name) self._ctrl.text(name) + # 等待输入同步 + time.sleep(0.1) def ensure_dismiss_keyboard(self) -> None: """点击空白区域关闭软键盘。""" @@ -181,6 +183,8 @@ def ensure_dismiss_keyboard(self) -> None: lambda screen: PixelChecker.check_signature(screen, INPUT_SIGNATURE).matched, timeout=5.0, ) + # 等待键盘关闭 + time.sleep(0.2) def click_first_result(self) -> None: """点击搜索结果中的第一个舰船。""" From eee4576a186b430337f4b937f977e0f281a1a44c Mon Sep 17 00:00:00 2001 From: KUAI Date: Mon, 27 Jul 2026 15:17:52 +0800 Subject: [PATCH 09/14] add decisive suggestions --- examples/usersettings.full.yaml | 3 ++- usersettings.yaml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/usersettings.full.yaml b/examples/usersettings.full.yaml index 0ae21d36..caa2f9dc 100644 --- a/examples/usersettings.full.yaml +++ b/examples/usersettings.full.yaml @@ -107,6 +107,7 @@ daily_automation: # ═══════════════════════════════════════════ # 决战 (不需要可删除整个 decisive_battle 块) +# 提示:决战舰队不要选择名字会滚动的舰船,否则容易导致识别错误 # ═══════════════════════════════════════════ decisive_battle: chapter: 6 # 决战章节 (1-6) @@ -114,7 +115,7 @@ decisive_battle: - 鲃鱼 - U-1206 - U-47 - - 射水鱼 + - "351" - U-96 - U-1405 level2: # 二级舰队 diff --git a/usersettings.yaml b/usersettings.yaml index 85999a78..a376de08 100644 --- a/usersettings.yaml +++ b/usersettings.yaml @@ -107,6 +107,7 @@ daily_automation: # ═══════════════════════════════════════════ # 决战 (不需要可删除整个 decisive_battle 块) +# 提示:决战舰队不要选择名字会滚动的舰船,否则容易导致识别错误 # ═══════════════════════════════════════════ decisive_battle: chapter: 1 # 决战章节 (1-6) From 1b154674e53f2bef38220b132a01c5c96f6f5866 Mon Sep 17 00:00:00 2001 From: KUAI Date: Mon, 27 Jul 2026 15:31:07 +0800 Subject: [PATCH 10/14] add destroy info --- examples/usersettings.full.yaml | 3 +++ usersettings.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/examples/usersettings.full.yaml b/examples/usersettings.full.yaml index caa2f9dc..25653d4f 100644 --- a/examples/usersettings.full.yaml +++ b/examples/usersettings.full.yaml @@ -67,6 +67,9 @@ bathroom_count: 2 # 浴室修理槽位数 (auto_bath_repair 空 # ── 解装设置 ── destroy_ship_work_mode: 不启用 # 不启用 / 黑名单 / 白名单 +# 不启用: 解装所有舰船 +# 黑名单: 解装列表中的舰种 +# 白名单: 解装不在列表中的舰种 destroy_ship_types: [] # 指定舰种列表, 参照 ShipType 枚举使用中文 # - 航母 # - 潜艇 diff --git a/usersettings.yaml b/usersettings.yaml index a376de08..6e5565e1 100644 --- a/usersettings.yaml +++ b/usersettings.yaml @@ -67,6 +67,9 @@ bathroom_count: 2 # 浴室修理槽位数 (auto_bath_repair 空 # ── 解装设置 ── destroy_ship_work_mode: 不启用 # 不启用 / 黑名单 / 白名单 +# 不启用: 解装所有舰船 +# 黑名单: 解装列表中的舰种 +# 白名单: 解装不在列表中的舰种 destroy_ship_types: [] # 指定舰种列表, 参照 ShipType 枚举使用中文 # - 航母 # - 潜艇 From 68e910eace8423fbdef58a5e31ed949f92dfe161 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 20:34:32 +0800 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E6=89=8B?= =?UTF-8?q?=E5=8A=BF=E6=8E=A7=E5=88=B6=E9=97=B4=E9=9A=94=E5=92=8C=E4=BF=AE?= =?UTF-8?q?=E7=90=86=E5=88=97=E8=A1=A8=E5=8F=AF=E8=A7=81=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=95=B0=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/emulator/controller/scrcpy.py | 11 ++++++++-- autowsgr/infra/config_compat.py | 5 +++-- autowsgr/ui/bath_page/page.py | 29 +++++++++++++++++++++++--- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index 71a905a6..a2536319 100644 --- a/autowsgr/emulator/controller/scrcpy.py +++ b/autowsgr/emulator/controller/scrcpy.py @@ -71,6 +71,9 @@ # 作为有符号 int64 写入 q 格式即 -2,对应无符号 0xFFFFFFFFFFFFFFFE _POINTER_ID_FINGER = -2 +# 同一手势内相邻控制消息(如 DOWN→UP、按键 DOWN→UP)之间的最小间隔。 +_MIN_GESTURE_INTERVAL = 0.01 # 10ms + class ScrcpyController(AndroidController): """基于 scrcpy 协议的 Android 设备控制器。 @@ -469,7 +472,6 @@ def _send_control(self, data: bytes) -> None: self._ensure_stream_alive() sock = self._require_control_socket() sock.sendall(data) - time.sleep(0.05) # 避免连续发送过快导致游戏处理不及 @staticmethod def _float_to_u16fp(value: float) -> int: @@ -612,6 +614,7 @@ def click(self, x: float, y: float, *, delay: bool = True) -> None: caller_info(), ) self._inject_touch(_ACTION_DOWN, x, y, pressure=1.0) + time.sleep(_MIN_GESTURE_INTERVAL) # DOWN/UP 间留出最小间隔,防止游戏来不及处理 self._inject_touch(_ACTION_UP, x, y, pressure=0.0) if delay: # True 才走延迟 @@ -645,6 +648,7 @@ def swipe( ) # 按下 self._inject_touch(_ACTION_DOWN, x1, y1, pressure=1.0) + time.sleep(_MIN_GESTURE_INTERVAL) # DOWN 后留出最小间隔,再开始 MOVE 插值 # 在 duration 内插值若干 MOVE 事件,保证流畅滑动 steps = max(1, ms // 16) # ~60fps,每步约 16ms step_ms = ms / steps @@ -653,7 +657,9 @@ def swipe( cx = x1 + (x2 - x1) * t cy = y1 + (y2 - y1) * t self._inject_touch(_ACTION_MOVE, cx, cy, pressure=1.0) - time.sleep(step_ms / 1000.0) + # 每步之间的间隔就是 swipe 本身的节奏(约 16ms/步), + # 已经足够避免连续发送过快;max() 仅在极短 duration 下兜底。 + time.sleep(max(step_ms / 1000.0, _MIN_GESTURE_INTERVAL)) # 抬起 self._inject_touch(_ACTION_UP, x2, y2, pressure=0.0) @@ -683,6 +689,7 @@ def key_event(self, key_code: int, *, delay: bool = True) -> None: _log.debug('[Emulator] key_event({}) {}', key_code, caller_info()) # 发送 DOWN + UP 完成一次按键 self._inject_keycode(key_code, action=_KEY_ACTION_DOWN) + time.sleep(_MIN_GESTURE_INTERVAL) # DOWN/UP 间留出最小间隔 self._inject_keycode(key_code, action=_KEY_ACTION_UP) # 增加延迟,改动同 click_delay diff --git a/autowsgr/infra/config_compat.py b/autowsgr/infra/config_compat.py index 563084b7..2c971ed6 100644 --- a/autowsgr/infra/config_compat.py +++ b/autowsgr/infra/config_compat.py @@ -182,8 +182,9 @@ def _migrate_delay(data: dict[str, Any]) -> None: raw, ) return - data['operation_delay_min'] = delay - data['operation_delay_max'] = delay + # 仅在用户未显式设置 operation_delay_min/max 时回填, 避免覆盖已部分迁移的显式值 + data.setdefault('operation_delay_min', delay) + data.setdefault('operation_delay_max', delay) _log.info('[compat] delay={} 已迁移为 operation_delay_min/max。', delay) diff --git a/autowsgr/ui/bath_page/page.py b/autowsgr/ui/bath_page/page.py index fe3b3b32..348287c8 100644 --- a/autowsgr/ui/bath_page/page.py +++ b/autowsgr/ui/bath_page/page.py @@ -57,6 +57,14 @@ _log = get_logger('ui') +_MAX_VISIBLE_CARDS = 5 +"""选择修理 overlay 单屏最多完整可见的舰船卡片数 (约 5~6 张, 视分辨率而定)。 + +游戏按修理耗时降序排列舰船, 列表不循环。若某页识别到的卡片数少于此值, +说明该页未被填满, 即已到列表末尾 (含被黑名单排除的卡片), 无需再滑动 +翻页查找更多舰船——继续滑动只会重复看到同一批末尾卡片。 +""" + @dataclass(frozen=True, slots=True) class RepairShipInfo: @@ -327,6 +335,14 @@ def repair_ship(self, ship_name: str) -> int: _log.warning('[UI] 浴场已满, 无法修理 {}', ship_name) return -1 + if len(ships) < _MAX_VISIBLE_CARDS: + # 本页未填满一屏,说明已到修理列表末尾,再滑动也不会有新舰船 + _log.debug( + '[UI] 选择修理: 本页仅 {} 张卡片 (<{}), 已到列表末尾,停止翻页', + len(ships), + _MAX_VISIBLE_CARDS, + ) + break # 未找到,滑动翻页 self._swipe_left() @@ -355,12 +371,19 @@ def repair_longest(self, blacklist: set[str] | None = None) -> int: blocked = blacklist or set() target: RepairShipInfo | None = None for _ in range(10): - candidates = [ - s for s in self.recognize_repair_ships() if s.name and s.name not in blocked - ] + ships = self.recognize_repair_ships() + candidates = [s for s in ships if s.name and s.name not in blocked] if candidates: target = max(candidates, key=lambda s: s.repair_seconds) break + if len(ships) < _MAX_VISIBLE_CARDS: + # 本页未填满一屏 (含被黑名单排除的), 说明已到修理列表末尾 + _log.debug( + '[UI] 选择修理: 本页仅 {} 张卡片 (<{}), 已到列表末尾,停止翻页', + len(ships), + _MAX_VISIBLE_CARDS, + ) + break self._swipe_left() if target is None: From 1eefb9939771ef940674f2bbbf54459a8874f474 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 21:47:28 +0800 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/ui/battle/detection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autowsgr/ui/battle/detection.py b/autowsgr/ui/battle/detection.py index 889f35c2..f8f1e8df 100644 --- a/autowsgr/ui/battle/detection.py +++ b/autowsgr/ui/battle/detection.py @@ -49,7 +49,7 @@ class FleetInfo: ship_damage: dict[int, ShipDamageState] = field(default_factory=dict) """槽位号 (0-5) → 血量状态。""" - def to_ships(self, names: list[str | None] | None = None) -> list[Ship]: + def to_ships(self, names: list[str | None] | list[str] | None = None) -> list[Ship]: """将舰队信息转换为 Ship 列表。 Parameters @@ -72,7 +72,7 @@ def to_ships(self, names: list[str | None] | None = None) -> list[Ship]: continue name = '' if names and i < len(names) and names[i] is not None: - name = names[i] + name = names[i] or '' ships.append( Ship( name=name, From d8d9d54b29a93101139364dd303bf85503269c40 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 21:55:43 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BF=AE?= =?UTF-8?q?=E8=88=B9=E4=B8=80=E6=AC=A1=E4=B8=80=E6=9D=A1=E5=92=8C500?= =?UTF-8?q?=E8=88=B9=E8=BF=98=E4=BC=9A=E6=88=98=E6=96=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/context/game_context.py | 40 ++++++ autowsgr/ops/repair.py | 51 +++++--- autowsgr/scheduler/scheduler.py | 41 ++++++ autowsgr/scheduler/triggers.py | 16 +++ testing/ops/test_repair_unit.py | 127 ++++++++++++++++++ testing/ops/test_scheduler_unit.py | 200 ++++++++++++++++++++++++++++- 6 files changed, 449 insertions(+), 26 deletions(-) create mode 100644 testing/ops/test_repair_unit.py diff --git a/autowsgr/context/game_context.py b/autowsgr/context/game_context.py index 01f0393a..cfe0d906 100644 --- a/autowsgr/context/game_context.py +++ b/autowsgr/context/game_context.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +import time from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -215,3 +216,42 @@ def sync_after_combat( fleet_id, [s.damage_state.name for s in fleet.ships], ) + + def sync_daily_drop_counts(self) -> None: + """导航到出征面板, 识别今日已获取舰船/战利品数并同步每日计数器。 + + ``dropped_ship_count`` / ``dropped_loot_count`` 初始为 0, 仅靠战斗后 + 同步不可靠 (战斗未必掉落舰船/战利品), 故调度层在 ``run_daily`` 启动时 + 调本方法校准真实值, 避免依赖计数器的停止条件 (``stop_max_ship`` / + ``stop_max_loot``) 首次误判而触发一场多余的常规战。读取后返回主页面。 + + Raises + ------ + RuntimeError + OCR 引擎不可用, 无法识别掉落数量。调用方应据此**禁用**依赖计数器 + 的触发器 (常规战) 并提示用户, 而非降级 —— 降级会退化为"靠首场 + 战斗自行校准", 但战斗未必掉落, 计数器可能一直为 0 → 持续误触发。 + """ + from autowsgr.ops import goto_page + from autowsgr.types import PageName + from autowsgr.ui import MapPage, MapPanel + + goto_page(self, PageName.MAP) + map_page = MapPage(self) + map_page.ensure_panel(MapPanel.SORTIE) + time.sleep(0.25) + # OCR 引擎不可用时 get_loot_and_ship_count 抛 RuntimeError — 不吞, 上抛 + counts = map_page.get_loot_and_ship_count() + if counts.ship is not None: + self.dropped_ship_count = counts.ship + if counts.loot is not None: + self.dropped_loot_count = counts.loot + _log.info( + '[Context] 每日掉落计数同步: 舰船 {}, 战利品 {}', + counts.ship, + counts.loot, + ) + try: + goto_page(self, PageName.MAIN) + except Exception: + _log.warning('[Context] 每日掉落计数同步后返回主页面失败') diff --git a/autowsgr/ops/repair.py b/autowsgr/ops/repair.py index 63056853..8180ba76 100644 --- a/autowsgr/ops/repair.py +++ b/autowsgr/ops/repair.py @@ -88,16 +88,17 @@ def repair_one_available( *, blacklist: list[str] | None = None, ) -> bool: - """有空闲修理槽时, 派修修理时间最长的非黑名单舰船 (调度入口)。 + """有空闲修理槽时, 派修直到填满所有空闲槽或无可修舰船 (调度入口)。 由 ``auto_daily`` 调度器以 :class:`~autowsgr.scheduler.triggers.TimerTrigger` 周期产出, 经优先级队列在所有战斗任务 (战役/演习/常规战) 完成后才执行 (空闲修船, 见 ``daily_plan.PRIO_BATH_REPAIR``)。流程: 1. ``ctx.bathroom`` 无空闲槽 → 直接返回 (省一次开 overlay)。 - 2. 导航浴室 → 开选择修理 overlay → 修最长非黑名单船。 - 3. 成功 → ``ctx.bathroom.occupy`` 记录释放时间, 返回 ``True``。 - 浴场满 → ``mark_unknown`` 退避, 返回 ``False``。 + 2. 导航浴室 → 循环 ``开选择修理 overlay → 修最长非黑名单船 → occupy 一个槽``, + 直到无空闲槽 / 无可修候选 / 浴场满。 + (游戏机制: 点击一艘船后 overlay 自动关闭, 故每修一艘需重开 overlay。) + 3. 派完后 ``ctx.bathroom.occupy`` 记录各槽释放时间, 返回主界面。 Parameters ---------- @@ -109,7 +110,7 @@ def repair_one_available( Returns ------- bool - ``True`` 成功派出一艘修理; ``False`` 无空位 / 无可修船 / 浴场满。 + ``True`` 至少派出一艘修理; ``False`` 无空位 / 无可修船 / 浴场满。 """ bath = ctx.bathroom bath.slot_count = ctx.config.bathroom_count @@ -117,27 +118,35 @@ def repair_one_available( _log.debug('[OPS] 浴室无空闲槽, 跳过修理') return False + blocked = set(blacklist or []) goto_page(ctx, PageName.BATH) page = BathPage(ctx) - page.go_to_choose_repair() - secs = page.repair_longest(blacklist=set(blacklist or [])) - - if secs > 0: - bath.occupy(secs) - _log.info('[OPS] 浴室修理派单成功 ({}s)', secs) - result = True - elif secs == -2: - # 浴场满: 状态不可靠, 标记未知下次重试 - bath.mark_unknown() - _log.warning('[OPS] 浴场已满, 稍后重试') - result = False - else: - # secs == -1: 无可修候选, 状态不变 - result = False + repaired = 0 + + # 循环填满所有空闲槽: 每修一艘 overlay 即自动关闭, 故每轮需重开 overlay。 + # 终止条件: 无空闲槽 (is_available False) / 无可修候选 (secs==-1) / + # 浴场满 (secs==-2)。空闲槽数 = slot_count, 故循环上限即槽位数, 不会死循环。 + while bath.is_available(): + page.go_to_choose_repair() + secs = page.repair_longest(blacklist=blocked) + + if secs > 0: + bath.occupy(secs) + repaired += 1 + _log.info('[OPS] 浴室修理派单成功 ({}s, 本轮已派 {} 艘)', secs, repaired) + continue + if secs == -2: + # 浴场满: 状态不可靠, 标记未知下次重试 + bath.mark_unknown() + _log.warning('[OPS] 浴场已满, 稍后重试 (本轮已派 {} 艘)', repaired) + break + # secs == -1 (无可修候选) 或 == 0 (修理时间解析失败): 状态不变, 结束派单 + _log.debug('[OPS] 无可修理舰船, 结束派单 (本轮已派 {} 艘)', repaired) + break time.sleep(1.0) try: goto_page(ctx, PageName.MAIN) except Exception: _log.warning('[OPS] 浴室修理后返回主界面失败') - return result + return repaired > 0 diff --git a/autowsgr/scheduler/scheduler.py b/autowsgr/scheduler/scheduler.py index d6e95c70..b8a46eed 100644 --- a/autowsgr/scheduler/scheduler.py +++ b/autowsgr/scheduler/scheduler.py @@ -317,6 +317,8 @@ def run_daily(self) -> None: len(self._triggers), ) self._last_date = date.today() # noqa: DTZ011 # 本地墙上时钟 + # 启动时校准每日掉落计数器 (避免首次常规战误触发, 见 _sync_initial_counts) + self._sync_initial_counts() while not self._ctx.stop_event.is_set(): self._check_daily_reset() @@ -413,6 +415,45 @@ def _check_daily_reset(self) -> None: self._ctx.quick_repair_used = 0 self._last_date = today + # ── 启动校准 ── + + def _sync_initial_counts(self) -> None: + """启动时校准每日掉落计数器, 避免常规战误触发。 + + 仅当启用了 ``stop_max_ship`` / ``stop_max_loot`` (二者依赖 ``ctx`` 每日 + 计数器判断是否达上限) 时执行; 否则跳过 (常规战无限打, 计数器无需校准)。 + + ``ctx.dropped_ship_count`` / ``dropped_loot_count`` 初始为 0; 若不同步, + 首次 :meth:`NormalFightTrigger.should_fire` 会因 ``0 >= limit`` 为假而误 + 产出一场常规战 (即使游戏内已达上限)。 + + 校准失败 (OCR 引擎不可用 / 导航异常) **不降级** —— 战斗未必掉落, 降级 + 靠首场战斗自行校准不可靠 (计数器可能一直为 0 → 持续误触发)。改为直接 + 禁用依赖计数器的常规战触发器并提示用户, 不阻塞主循环。 + """ + da = self._ctx.config.daily_automation + if da is None or not (da.stop_max_ship or da.stop_max_loot): + return + try: + self._ctx.sync_daily_drop_counts() + except Exception as exc: + _log.opt(exception=True).error( + '[Scheduler] 每日掉落计数器校准失败, 已禁用常规战触发器以避免误触发: {}', + exc, + ) + _log.error( + '[Scheduler] 请检查 OCR 引擎 (ocr_backend) 与截图/导航是否正常后重启脚本', + ) + self._disable_normal_fight(reason=str(exc)) + + def _disable_normal_fight(self, reason: str) -> None: + """禁用所有常规战触发器 (计数器无法校准时, 避免误触发)。""" + from autowsgr.scheduler.triggers import NormalFightTrigger + + for trigger in self._triggers: + if isinstance(trigger, NormalFightTrigger): + trigger.disable(reason=reason) + # ── 远征检查 ── def _maybe_collect_expedition(self) -> None: diff --git a/autowsgr/scheduler/triggers.py b/autowsgr/scheduler/triggers.py index 7fb9a3bb..378a0875 100644 --- a/autowsgr/scheduler/triggers.py +++ b/autowsgr/scheduler/triggers.py @@ -303,8 +303,24 @@ def __init__( self._current: NormalFightPlan | None = None # 无限 plan (target=None) 的轮询游标 self._round_robin = 0 + # 是否被禁用 (不再产出任务)。启动校准计数器失败 (OCR 不可用) 等场景由 + # 调度层设置; 持续整个会话, reset() 不清除 (OCR 可用性不跨日变化)。 + self._disabled = False + + def disable(self, reason: str = '') -> None: + """禁用本触发器 (不再产出任务)。 + + 用于依赖的每日计数器无法校准 (OCR 引擎不可用) 等场景 —— 与其降级后 + 靠首场战斗自行校准 (战斗未必掉落, 计数器可能一直为 0 → 持续误触发), + 不如直接禁用并提示用户。持续整个会话, :meth:`reset` 不清除。 + """ + if not self._disabled: + self._disabled = True + _log.warning('[Trigger] 常规战触发器已禁用: {}', reason) def should_fire(self, ctx: GameContext) -> FightTask | None: + if self._disabled: + return None if not self._idle: return None if self._is_exhausted(ctx): diff --git a/testing/ops/test_repair_unit.py b/testing/ops/test_repair_unit.py new file mode 100644 index 00000000..38b8c0e1 --- /dev/null +++ b/testing/ops/test_repair_unit.py @@ -0,0 +1,127 @@ +"""repair_one_available 循环派单单元测试 (无设备)。 + +回归: 浴室修理触发器每次产出应填满所有空闲槽, 而非只派一艘即返回。 +用真实 :class:`BathRoom` 状态机验证循环终止 (occupy 填满 → is_available False), +死循环会直接卡住测试暴露问题。 +""" + +from __future__ import annotations + +import types +from typing import TYPE_CHECKING + +from autowsgr.context.bathroom import BathRoom + + +if TYPE_CHECKING: + import pytest + + +# ── 替身 ── + + +class _FakeBathPage: + """BathPage 替身: 按预设序列返回 repair_longest 结果。""" + + def __init__(self, results: list[int]) -> None: + self._results = list(results) + self.repair_longest_calls = 0 + self.go_to_choose_repair_calls = 0 + + def go_to_choose_repair(self) -> None: + self.go_to_choose_repair_calls += 1 + + def repair_longest(self, blacklist: set[str] | None = None) -> int: # noqa: ARG002 # 签名匹配真实接口 (调用方按关键字传 blacklist=) + self.repair_longest_calls += 1 + return self._results.pop(0) if self._results else -1 + + +class _FakeCtx: + """最小 ctx 替身: 真实 bathroom 状态机 + bathroom_count 配置。""" + + def __init__(self, slot_count: int) -> None: + self.bathroom = BathRoom(slot_count=slot_count) + self.config = types.SimpleNamespace(bathroom_count=slot_count) + + +def _patch_repair(monkeypatch: pytest.MonkeyPatch, fake_page: _FakeBathPage) -> None: + """把 repair 模块的设备依赖 (goto_page / BathPage / sleep) 替换为 no-op。""" + import autowsgr.ops.repair as mod + + monkeypatch.setattr(mod, 'goto_page', lambda *_a, **_kw: None) + monkeypatch.setattr(mod, 'BathPage', lambda _ctx: fake_page) + monkeypatch.setattr(mod, 'time', types.SimpleNamespace(sleep=lambda *_a, **_kw: None)) + + +# ── 循环填满 ── + + +def test_fills_all_free_slots_then_stops(monkeypatch: pytest.MonkeyPatch): + """两空闲槽 → 连续派修两艘, 槽填满后停止 (不死循环)。""" + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([100, 200]) # 两次都派单成功 + _patch_repair(monkeypatch, fake) + + ctx = _FakeCtx(slot_count=2) + assert mod.repair_one_available(ctx) is True + + assert fake.repair_longest_calls == 2 # 恰好修 2 艘, 不是 1 也不是死循环 + assert fake.go_to_choose_repair_calls == 2 # 每艘都重开 overlay + assert ctx.bathroom.is_available() is False # 两槽全满 + + +def test_partial_fill_then_no_candidates(monkeypatch: pytest.MonkeyPatch): + """修一艘后剩余无可修候选 (secs==-1): 派 1 艘后停止, 仍留 1 空闲槽。""" + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([100, -1]) + _patch_repair(monkeypatch, fake) + + ctx = _FakeCtx(slot_count=2) + assert mod.repair_one_available(ctx) is True # 至少派了 1 艘 + + assert fake.repair_longest_calls == 2 # 第 2 次返回 -1 触发停止 + assert ctx.bathroom.is_available() is True # 仍有 1 空闲槽, 只是无船可修 + + +def test_no_candidates_first_try(monkeypatch: pytest.MonkeyPatch): + """首次即无可修候选 (secs==-1): 不占用任何槽, 返回 False。""" + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([-1]) + _patch_repair(monkeypatch, fake) + + ctx = _FakeCtx(slot_count=2) + assert mod.repair_one_available(ctx) is False + + assert fake.repair_longest_calls == 1 + assert ctx.bathroom.is_available() is True # 未占用 + + +def test_bath_full_marks_unknown(monkeypatch: pytest.MonkeyPatch): + """浴场满 (secs==-2): mark_unknown 退避, 返回 False。""" + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([-2]) + _patch_repair(monkeypatch, fake) + + ctx = _FakeCtx(slot_count=2) + assert mod.repair_one_available(ctx) is False + + assert ctx.bathroom.available_time is None # mark_unknown → 强制下次重试 + + +def test_skip_when_no_free_slot(monkeypatch: pytest.MonkeyPatch): + """无空闲槽 → 直接返回, 不开 overlay (省一次截图)。""" + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([]) # 不应被调用 + _patch_repair(monkeypatch, fake) + + ctx = _FakeCtx(slot_count=1) + ctx.bathroom.occupy(999) # 占满唯一槽 + assert ctx.bathroom.is_available() is False + + assert mod.repair_one_available(ctx) is False + assert fake.go_to_choose_repair_calls == 0 # 没开 overlay diff --git a/testing/ops/test_scheduler_unit.py b/testing/ops/test_scheduler_unit.py index dead16a3..eafda226 100644 --- a/testing/ops/test_scheduler_unit.py +++ b/testing/ops/test_scheduler_unit.py @@ -8,17 +8,16 @@ from __future__ import annotations import threading -from typing import TYPE_CHECKING +import types + +import pytest from autowsgr.combat import CombatResult +from autowsgr.context import GameContext from autowsgr.scheduler.scheduler import BatchRunnerAdapter, FightTask, TaskScheduler from autowsgr.types import ConditionFlag -if TYPE_CHECKING: - import pytest - - # ── 假 runner ── @@ -218,3 +217,194 @@ def test_starvation_not_warned_when_bath_repair_off(): normal_fight_tasks=[{'name': 'x'}], ) assert _bath_repair_starved_by_normal_fight(cfg) is False + + +# ── 启动计数器校准 (避免常规战误触发) ── + + +class _FakeDA: + """daily_automation 替身: 仅暴露 stop_max_ship / stop_max_loot。""" + + def __init__(self, stop_max_ship: bool = False, stop_max_loot: bool = False) -> None: + self.stop_max_ship = stop_max_ship + self.stop_max_loot = stop_max_loot + + +def _ctx_with_da(da: _FakeDA | None) -> _FakeCtx: + ctx = _FakeCtx() + ctx.config = types.SimpleNamespace(daily_automation=da) + return ctx + + +def test_sync_initial_counts_skipped_when_no_stop_limit(): + """未启用 stop_max_ship/loot → 不校准 (常规战无限打, 计数器无需校准)。""" + ctx = _ctx_with_da(_FakeDA(stop_max_ship=False, stop_max_loot=False)) + called: list = [] + ctx.sync_daily_drop_counts = lambda: called.append(1) # type: ignore[method-assign] + + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + sched._sync_initial_counts() + assert called == [] + + +def test_sync_initial_counts_runs_when_stop_max_ship(): + """启用 stop_max_ship → 调 ctx.sync_daily_drop_counts 校准。""" + ctx = _ctx_with_da(_FakeDA(stop_max_ship=True)) + called: list = [] + ctx.sync_daily_drop_counts = lambda: called.append(1) # type: ignore[method-assign] + + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + sched._sync_initial_counts() + assert called == [1] + + +def test_sync_initial_counts_runs_when_stop_max_loot(): + """启用 stop_max_loot (未启用 ship) → 仍校准。""" + ctx = _ctx_with_da(_FakeDA(stop_max_loot=True)) + called: list = [] + ctx.sync_daily_drop_counts = lambda: called.append(1) # type: ignore[method-assign] + + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + sched._sync_initial_counts() + assert called == [1] + + +def test_sync_initial_counts_skipped_when_da_none(): + """未配置 daily_automation (None) → 不校准。""" + ctx = _ctx_with_da(None) + called: list = [] + ctx.sync_daily_drop_counts = lambda: called.append(1) # type: ignore[method-assign] + + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + sched._sync_initial_counts() + assert called == [] + + +def test_sync_initial_counts_disables_normal_fight_on_failure(): + """校准抛异常 → 不降级, 直接禁用 NormalFightTrigger 并提示 (不阻塞主循环)。""" + from autowsgr.scheduler.triggers import NormalFightPlan, NormalFightTrigger + + def boom() -> None: + raise RuntimeError('OCR 不可用') + + ctx = _ctx_with_da(_FakeDA(stop_max_ship=True)) + ctx.sync_daily_drop_counts = boom # type: ignore[method-assign] + + plan = NormalFightPlan(factory=lambda _c: object(), name='x', fleet_id=1) + trigger = NormalFightTrigger(priority=100, name='常规战', plans=[plan]) + + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + sched.register_trigger(trigger) + sched._sync_initial_counts() # 不抛 + + assert trigger._disabled is True + assert trigger.should_fire(ctx) is None # 禁用后不再产出 + + +# ── GameContext.sync_daily_drop_counts (识别 + 同步每日计数器) ── + + +class _FakeSortiePage: + """MapPage 替身: ensure_panel no-op, get_loot_and_ship_count 返回预设值。""" + + def __init__( + self, + *, + ship: int | None, + loot: int | None, + raises: type[Exception] | None = None, + ) -> None: + self._ship = ship + self._loot = loot + self._raises = raises + + def ensure_panel(self, _panel: object) -> None: + return None + + def get_loot_and_ship_count(self) -> types.SimpleNamespace: + if self._raises is not None: + raise self._raises('OCR 不可用') + return types.SimpleNamespace(ship=self._ship, loot=self._loot) + + +def _patch_ctx_sortie(monkeypatch: pytest.MonkeyPatch, page: _FakeSortiePage) -> None: + """替换 ctx.sync_daily_drop_counts 的设备依赖 (goto_page / MapPage / sleep) 为 no-op。""" + import autowsgr.context.game_context as gc_mod + import autowsgr.ops as ops_mod + import autowsgr.ui as ui_mod + + monkeypatch.setattr(ops_mod, 'goto_page', lambda *_a, **_kw: None) + monkeypatch.setattr(ui_mod, 'MapPage', lambda _ctx: page) + monkeypatch.setattr(gc_mod, 'time', types.SimpleNamespace(sleep=lambda *_a: None)) + + +def _make_ctx() -> GameContext: + return GameContext(ctrl=object(), config=types.SimpleNamespace(), ocr=object()) # type: ignore[arg-type] + + +def test_ctx_sync_drop_counts_writes_both(monkeypatch: pytest.MonkeyPatch): + """读到真实计数 → 同步到 dropped_ship_count / dropped_loot_count。""" + _patch_ctx_sortie(monkeypatch, _FakeSortiePage(ship=500, loot=50)) + ctx = _make_ctx() + ctx.dropped_ship_count = 0 + ctx.dropped_loot_count = 0 + + ctx.sync_daily_drop_counts() + assert ctx.dropped_ship_count == 500 + assert ctx.dropped_loot_count == 50 + + +def test_ctx_sync_drop_counts_ignores_none(monkeypatch: pytest.MonkeyPatch): + """单项 OCR 解析失败 (None) → 不覆盖, 只同步成功的那项。""" + _patch_ctx_sortie(monkeypatch, _FakeSortiePage(ship=None, loot=50)) + ctx = _make_ctx() + ctx.dropped_ship_count = 0 + ctx.dropped_loot_count = 0 + + ctx.sync_daily_drop_counts() + assert ctx.dropped_ship_count == 0 # None 未覆盖 + assert ctx.dropped_loot_count == 50 + + +def test_ctx_sync_drop_counts_raises_on_ocr_unavailable(monkeypatch: pytest.MonkeyPatch): + """OCR 引擎不可用 (RuntimeError) → 抛出, 不降级, 计数保持不变。""" + _patch_ctx_sortie(monkeypatch, _FakeSortiePage(ship=500, loot=50, raises=RuntimeError)) + ctx = _make_ctx() + ctx.dropped_ship_count = 0 + ctx.dropped_loot_count = 0 + + with pytest.raises(RuntimeError): + ctx.sync_daily_drop_counts() + assert ctx.dropped_ship_count == 0 # 未同步 + assert ctx.dropped_loot_count == 0 + + +# ── NormalFightTrigger.disable ── + + +def test_normal_fight_trigger_disable_stops_production(): + """disable() 后 should_fire 永远返回 None。""" + from autowsgr.scheduler.triggers import NormalFightPlan, NormalFightTrigger + + plan = NormalFightPlan(factory=lambda _c: object(), name='x', fleet_id=1) + ctx = _ctx_with_da(None) + trigger = NormalFightTrigger(priority=100, name='常规战', plans=[plan]) + assert trigger.should_fire(ctx) is not None # 未禁用能产出 + + trigger.disable(reason='OCR 不可用') + assert trigger._disabled is True + assert trigger.should_fire(ctx) is None # 禁用后不产出 + + +def test_normal_fight_trigger_disable_survives_reset(): + """reset() (跨日) 不清除禁用 (OCR 可用性不跨日变化)。""" + from autowsgr.scheduler.triggers import NormalFightPlan, NormalFightTrigger + + plan = NormalFightPlan(factory=lambda _c: object(), name='x', fleet_id=1) + ctx = _ctx_with_da(None) + trigger = NormalFightTrigger(priority=100, name='常规战', plans=[plan]) + trigger.disable(reason='OCR 不可用') + trigger.reset() + + assert trigger._disabled is True + assert trigger.should_fire(ctx) is None From 860d35d0aefe65e54cecaf881a9ab6696dd20631 Mon Sep 17 00:00:00 2001 From: KUAI Date: Mon, 27 Jul 2026 22:22:27 +0800 Subject: [PATCH 14/14] fix node recognize --- autowsgr/combat/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autowsgr/combat/actions.py b/autowsgr/combat/actions.py index 5f0008e9..01309e90 100644 --- a/autowsgr/combat/actions.py +++ b/autowsgr/combat/actions.py @@ -190,8 +190,8 @@ def click_speed_up(device: AndroidController, *, battle_mode: bool = False) -> N coords = Coords.SPEED_UP_BATTLE if battle_mode else Coords.SPEED_UP_NORMAL # 由于加速过快可能导致卡顿, 增加极短延迟以确保点击生效 - device.click(*coords, delay=False) time.sleep(0.05) + device.click(*coords, delay=False) def click_skip_missile_animation(device: AndroidController) -> None: