From 2c0104b92e5901738adaa86baf97fdcb88c0ac28 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Sun, 26 Jul 2026 18:57:50 +0800 Subject: [PATCH 1/7] 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 77c57f04f491693eccc77904abd602d515d66606 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Sun, 26 Jul 2026 20:28:06 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=B8=85=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/emulator/controller/scrcpy.py | 18 +- testing/emulator/test_controller.py | 245 +++++++++++-------------- 2 files changed, 117 insertions(+), 146 deletions(-) diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index f856fc16..9843892b 100644 --- a/autowsgr/emulator/controller/scrcpy.py +++ b/autowsgr/emulator/controller/scrcpy.py @@ -470,6 +470,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 +478,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 +497,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 +601,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, @@ -627,9 +633,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 {}', @@ -668,8 +673,7 @@ def swipe( ) 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 {}', 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 1cecd3b10b48da892ac8dc016799ed3b7ad3bf46 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 01:48:25 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(config):=20=E9=9B=86=E4=B8=AD=E5=90=91?= =?UTF-8?q?=E4=B8=8B=E5=85=BC=E5=AE=B9=E5=B1=82=E4=B8=8E=E6=AD=BB=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=B8=85=E7=90=86,=E6=94=AF=E6=8C=81=20classic=20?= =?UTF-8?q?=E8=80=81=E9=85=8D=E7=BD=AE=E8=87=AA=E5=8A=A8=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_compat.migrate_raw_config 集中处理 classic 遗留字段 (ship_name_file/account/password 删除提示; delay→OPERATION_DELAY; 平铺 emulator_type/start_cmd/name→嵌套 emulator 块; check_update/ show_map_node 清理), 支持 source_path 写回使迁移一次性生效 - config.py 删 delay/check_page/ship_name_file 及 7 个死 log 开关, 新增 bath_repair_blacklist 与 operation_delay() 运行期延迟函数 - scrcpy.py 改调 operation_delay(), 修复 from import 值绑定致改全局延迟不生效 Co-Authored-By: Claude --- autowsgr/emulator/controller/scrcpy.py | 45 +----- autowsgr/infra/config.py | 116 +++++++++++--- autowsgr/infra/config_compat.py | 210 +++++++++++++++++++++++++ autowsgr/infra/file_utils.py | 62 +++++--- 4 files changed, 352 insertions(+), 81 deletions(-) create mode 100644 autowsgr/infra/config_compat.py diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index 9843892b..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 @@ -616,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, @@ -665,12 +659,7 @@ 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: px, py = self._to_absolute(x, y) @@ -698,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()) @@ -716,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()) # ── 应用管理 ── @@ -732,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() @@ -746,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/autowsgr/infra/config.py b/autowsgr/infra/config.py index 1b6decae..8569ad12 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,24 @@ 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`` 表示无限 (空闲填充, 仅受全局上限约束)。""" + + class DailyAutomationConfig(BaseModel): """日常自动化设置。""" @@ -179,6 +204,8 @@ class DailyAutomationConfig(BaseModel): """空闲时自动澡堂修理""" auto_set_support: bool = False """自动开启战役支援""" + bath_repair_blacklist: list[str] = Field(default_factory=list) + """浴室修理黑名单:这些舰船名(中文全名)不会被自动浴室修理。""" # 战役 auto_battle: bool = True @@ -206,8 +233,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 +242,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 +338,16 @@ class UserConfig(BaseModel): """操作系统类型,自动检测""" # 脚本行为 - delay: float = 1.5 - """延迟时间基本单位 (秒)""" - check_page: bool = True - """启动时是否检查游戏页面""" + # delay / check_page 已移除:delay 由兼容层映射为 OPERATION_DELAY_MIN/MAX + # (见 config_compat);check_page 功能已由 launcher.ensure_ready 覆盖。 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 +381,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: @@ -359,8 +419,16 @@ def _resolve_emulator_defaults(self) -> UserConfig: @classmethod def from_yaml(cls, path: str | Path) -> UserConfig: - """从 YAML 文件加载配置。""" + """从 YAML 文件加载配置。 + + raw dict 先经 :func:`migrate_raw_config` 做向下兼容处理 + (废弃字段提示 / delay 迁移); 若检测到迁移改动, 会把结果**写回原文件** + (仅含用户已定义字段, 不写入默认值), 使迁移一次性生效。最后交 Pydantic 校验。 + """ + from autowsgr.infra.config_compat import migrate_raw_config + data = load_yaml(path) + data = migrate_raw_config(data, source_path=path) 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..4b2c6e52 --- /dev/null +++ b/autowsgr/infra/config_compat.py @@ -0,0 +1,210 @@ +"""向下兼容层 — 集中处理用户 YAML 中的废弃 / 迁移字段。 + +所有 classic 遗留配置的迁移、删除提示、自动映射都集中在本模块, +不分散到各子模型。在 :func:`UserConfig.from_yaml` 的 +``model_validate`` 之前对 raw dict 做一次性预处理。 + +设计要点 +-------- +- 迁移 / 删除在内存 raw dict 上原地完成; 若调用方传入 ``source_path`` + 且检测到改动, 会把迁移后的配置**写回原文件**, 使迁移一次性生效、后续 + 不再重复告警。回写的是 raw dict (``model_validate`` 之前), 故**只含 + 用户已定义字段**, 不会把 Pydantic 默认值灌进用户文件。 +- 删除的字段因子模型默认 ``extra='ignore'`` 不会报错; 本模块额外**主动提示** + 用户删除, 避免旧字段在配置里造成误导。 +- ``delay`` (classic 单值秒数) 自动映射到模块全局 + :data:`~autowsgr.infra.config.OPERATION_DELAY_MIN` / + :data:`~autowsgr.infra.config.OPERATION_DELAY_MAX`, + 由 :func:`~autowsgr.infra.config.operation_delay` 运行期读取。 +""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING, Any + +from autowsgr.infra import config as _config +from autowsgr.infra.file_utils import save_yaml +from autowsgr.infra.logger import get_logger + + +if TYPE_CHECKING: + from pathlib import Path + + +_log = get_logger('infra') + + +def migrate_raw_config( + data: dict[str, Any], + *, + source_path: str | Path | None = None, +) -> dict[str, Any]: + """预处理用户 raw 配置 dict: 迁移 / 删除废弃字段并提示。 + + 在 ``UserConfig.model_validate`` 之前调用。原地修改并返回同一对象。 + 集中处理 classic 遗留字段, 见模块文档字符串。 + + 若给出 *source_path* 且检测到 dict 发生改动, 会把迁移后的 dict 写回 + 该文件 (best-effort: 写回失败仅告警, 不影响本次加载), 使迁移一次性 + 生效、后续运行不再重复告警。回写的是 raw dict, 不含 Pydantic 默认值。 + """ + if not isinstance(data, dict): + return data + + before = copy.deepcopy(data) + + _migrate_ship_name_file(data) + _migrate_account(data) + _migrate_delay(data) + _migrate_emulator_legacy(data) + _migrate_misc_legacy(data) + + if source_path is not None and data != before: + _persist(data, source_path) + + return data + + +def _persist(data: dict[str, Any], source_path: str | Path) -> None: + """把迁移后的 raw dict 写回 *source_path* (best-effort)。 + + 只在迁移确有改动时调用。写回失败不抛异常 —— 迁移已在内存生效, + 本次加载不受影响; 仅告警提示用户手动整理配置文件。 + """ + try: + save_yaml(data, source_path) + except Exception as e: # 写回是 best-effort 副作用, 不得阻断配置加载 + _log.warning( + '[compat] 迁移后写回配置文件失败 ({}): {!r}; 本次按内存迁移结果继续, ' + '请手动按新版格式整理配置文件。', + source_path, + e, + ) + return + _log.info('[compat] 迁移后的配置已写回 {}', source_path) + + +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 = MAX = 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 + _config.OPERATION_DELAY_MIN = delay + _config.OPERATION_DELAY_MAX = delay + _log.warning( + '[compat] delay={} 已自动映射为 OPERATION_DELAY_MIN/MAX。' + '该字段已从配置移除, 后续请直接设置 OPERATION_DELAY。', + delay, + ) + + +# classic 平铺模拟器字段 (顶层) → dev 嵌套 emulator 块键名 +_LEGACY_EMULATOR_FIELDS: dict[str, str] = { + 'emulator_type': 'type', + 'emulator_start_cmd': 'path', + 'emulator_name': 'serial', +} + + +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 ', 未写入新值', + ) + + +# classic 顶层废弃字段 (dev 不使用, 子模型 extra='ignore' 会静默丢弃) +_LEGACY_TOPLEVEL_DROPPED: tuple[str, ...] = ( + 'check_update', + 'show_map_node', +) + + +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), + ) 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: From f76ede372e47836ee9a615da3bf0c916d56141ff Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 01:50:59 +0800 Subject: [PATCH 4/7] =?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 - 新增 triggers.py + daily_plan.py: 触发器+优先级队列架构, 注册远征/任务奖励/浴室修理/战役/演习/常规战 - TaskScheduler.run_daily 触发器驱动主循环; _run_task 无条件用 BatchRunnerAdapter 包装, 修复 CampaignRunner 返回 list 时 on_done 回调 'list' object has no attribute 'flag' 崩溃 (isinstance @runtime_checkable 不查返回类型致误判) - BathRoom 槽位状态机 + repair_longest + repair_one_available (完整空位调度 + 维修黑名单) - destroy_ships_auto 三模式 (disable/include/exclude); ensure_battle_support (auto_set_support) 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 | 43 +++- autowsgr/ops/event_fight.py | 9 +- autowsgr/ops/exercise.py | 29 ++- autowsgr/ops/normal_fight.py | 39 ++-- autowsgr/ops/repair.py | 61 ++++- autowsgr/scheduler/__init__.py | 14 +- autowsgr/scheduler/daily_plan.py | 236 +++++++++++++++++++ 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, 1248 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..d16d5c36 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..d1762922 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,44 @@ 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 船坞满时调用, 统一读取配置: + + - ``destroy_ship_work_mode == disable``: 不解装, 返回 ``False`` + (调用方据此停止战斗, 视为船坞满)。 + - ``include`` (黑名单): 解装 ``destroy_ship_types`` 指定舰种。 + - ``exclude`` (白名单): 解装除 ``destroy_ship_types`` 外的所有舰种。 + + ``remove_equipment`` 取自 ``remove_equipment_mode``。 + + Returns + ------- + bool + ``True`` 已执行解装; ``False`` 配置为「不启用」。 + """ + cfg = ctx.config + mode = cfg.destroy_ship_work_mode + + if mode == DestroyShipWorkMode.disable: + _log.info('[OPS] 解装模式为「不启用」, 跳过自动解装') + return False + + if 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..c32c29be 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 + # 否则 work_mode=disable, 保持 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..15e06c9d 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,61 @@ 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: + """有空闲修理槽时, 派修修理时间最长的非黑名单舰船 (调度入口)。 + + 由 ``BathRepairTrigger`` 周期调用。流程: + + 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..12e0d13a --- /dev/null +++ b/autowsgr/scheduler/daily_plan.py @@ -0,0 +1,236 @@ +"""auto_daily 计划构建 — 把 :class:`DailyAutomationConfig` 翻译成触发器。 + +激活 dev 的「死配置」``daily_automation``: 读取其字段, 为每个启用的日常任务 +(远征 / 战役 / 演习 / 常规战) 构造触发器并注册到 :class:`TaskScheduler`。 + +优先级 (数值小先执行, 由 :mod:`autowsgr.scheduler.triggers` 定义):: + + 远征 0 < 战役 5 < 演习 10 < 常规战 100 (空闲填充) + +使用方式:: + + 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_BATH_REPAIR = 2 +PRIO_CAMPAIGN = 5 +PRIO_EXERCISE = 10 +PRIO_NORMAL_FIGHT = 100 + +# 远征定时触发间隔 (秒)。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 2, 完整空位调度 + 黑名单) ── + 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) + + if not scheduler._triggers: + _log.warning('[daily] 未启用任何日常任务, run_daily 将空转') + + +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 906f11a544b504f265903cdfe58c1727c293e53d Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 01:51:36 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=E5=90=8C=E6=AD=A5=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E9=85=8D=E7=BD=AE=E5=B9=B6=E8=A1=A5=E5=85=A8=20config?= =?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 - usersettings.yaml/full.yaml 删废弃字段、加 bath_repair_blacklist - 新增 test_bathroom / test_destroy_unit / test_scheduler_unit - test_config 补 compat 迁移与写回测试 Co-Authored-By: Claude --- examples/usersettings.full.yaml | 17 +-- testing/context/__init__.py | 0 testing/context/test_bathroom.py | 97 ++++++++++++++++ testing/infra/test_config.py | 171 ++++++++++++++++++++++++++++- testing/infra/test_file_utils.py | 62 +++++++++++ testing/ops/test_destroy_unit.py | 100 +++++++++++++++++ testing/ops/test_scheduler_unit.py | 129 ++++++++++++++++++++++ usersettings.yaml | 19 +--- 8 files changed, 563 insertions(+), 32 deletions(-) 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/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/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_config.py b/testing/infra/test_config.py index 2c5ee902..aa60e18e 100644 --- a/testing/infra/test_config.py +++ b/testing/infra/test_config.py @@ -12,6 +12,7 @@ EmulatorConfig, FightConfig, UserConfig, + load_yaml, ) from autowsgr.types import ( DestroyShipWorkMode, @@ -27,6 +28,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 ── @@ -65,8 +78,8 @@ def test_from_yaml(self, tmp_yaml: Callable[[str, str], Path]): 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 not hasattr(cfg, 'delay') def test_with_daily_automation(self, tmp_yaml: Callable[[str, str], Path]): content = """\ @@ -157,12 +170,164 @@ def test_load_existing_file(self, tmp_yaml: Callable[[str, str], Path]): path = tmp_yaml('settings.yaml', content) cfg = ConfigManager.load(path) assert cfg.emulator.type == EmulatorType.mumu - assert cfg.delay == 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: + """向下兼容迁移: migrate_raw_config + operation_delay。""" + + 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_mapped_to_operation_delay(self): + from autowsgr.infra import config + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'delay': 1.5}) + assert 'delay' not in out + assert config.OPERATION_DELAY_MIN == 1.5 + assert config.OPERATION_DELAY_MAX == 1.5 + + 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, tmp_yaml: Callable[[str, str], Path]): + """classic 平铺 emulator_type/start_cmd/name → 嵌套 emulator 块, 值透传。""" + content = """\ +emulator_type: "MuMu" +emulator_start_cmd: "C:/fake/MuMuPlayer.exe" +emulator_name: "127.0.0.1:16384" +""" + path = tmp_yaml('emu_legacy.yaml', content) + cfg = UserConfig.from_yaml(path) + assert cfg.emulator.type == EmulatorType.mumu + assert cfg.emulator.path == 'C:/fake/MuMuPlayer.exe' + assert cfg.emulator.serial == '127.0.0.1:16384' + + 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 + + # ── 写回 (source_path 持久化) ── + + def test_legacy_emulator_fields_written_back( + self, + tmp_yaml: Callable[[str, str], Path], + ): + """from_yaml 应把迁移结果写回原文件: 平铺→嵌套, 且不灌入默认字段。""" + content = """\ +emulator_type: "MuMu" +emulator_start_cmd: "C:/fake/MuMuPlayer.exe" +emulator_name: "127.0.0.1:16384" +""" + path = tmp_yaml('emu_writeback.yaml', content) + UserConfig.from_yaml(path) + + rewritten = load_yaml(path) + # 平铺字段已消失, 值搬进嵌套 emulator 块 + assert 'emulator_type' not in rewritten + assert 'emulator_start_cmd' not in rewritten + assert 'emulator_name' not in rewritten + assert rewritten['emulator'] == { + 'type': 'MuMu', + 'path': 'C:/fake/MuMuPlayer.exe', + 'serial': '127.0.0.1:16384', + } + # 关键: 不得写入用户从未定义的 Pydantic 默认字段 + assert 'dock_full_destroy' not in rewritten + assert 'os_type' not in rewritten + assert 'repair_manually' not in rewritten + + def test_writeback_idempotent(self, tmp_yaml: Callable[[str, str], Path]): + """迁移写回后, 再次加载不应再改动文件 (一次性生效)。""" + content = """\ +emulator_type: "MuMu" +emulator_start_cmd: "C:/fake/MuMuPlayer.exe" +emulator_name: "127.0.0.1:16384" +""" + path = tmp_yaml('emu_idempotent.yaml', content) + UserConfig.from_yaml(path) + first = load_yaml(path) + # 第二次加载: 平铺字段已不在, 无迁移发生, 文件应原样不动 + UserConfig.from_yaml(path) + assert load_yaml(path) == first + + def test_clean_config_not_rewritten(self, tmp_yaml: Callable[[str, str], Path]): + """无需迁移的干净配置, 文件字节应原样保留 (不触发写回)。""" + 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') + UserConfig.from_yaml(path) + assert path.read_text(encoding='utf-8') == before + + def test_migrate_without_source_path_does_not_persist(self): + """不传 source_path 时, 迁移只在内存生效 (向后兼容旧调用)。""" + from autowsgr.infra.config_compat import migrate_raw_config + + out = migrate_raw_config({'emulator_type': 'MuMu'}) + # 内存里照常迁移 + assert out['emulator'] == {'type': 'MuMu'} + assert 'emulator_type' not in out # ── LogConfig (setup_logger) ── 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..843a2da8 --- /dev/null +++ b/testing/ops/test_destroy_unit.py @@ -0,0 +1,100 @@ +"""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_skips_and_returns_false(recorded: list[dict]): + from autowsgr.ops.destroy import destroy_ships_auto + + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.disable)) + assert destroy_ships_auto(ctx) is False + assert recorded == [] + + +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..56f05883 --- /dev/null +++ b/testing/ops/test_scheduler_unit.py @@ -0,0 +1,129 @@ +"""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] 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 d68d1ffd0e1519cab642eefabe874bd3cf9c5e02 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 02:40:50 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Ddestroy=5Fship=5Fw?= =?UTF-8?q?ork=5Fmode=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/ops/decisive/chapter.py | 4 ++-- autowsgr/ops/destroy.py | 18 ++++++++++-------- autowsgr/ops/normal_fight.py | 2 +- testing/ops/test_destroy_unit.py | 7 ++++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/autowsgr/ops/decisive/chapter.py b/autowsgr/ops/decisive/chapter.py index d16d5c36..839d3d18 100644 --- a/autowsgr/ops/decisive/chapter.py +++ b/autowsgr/ops/decisive/chapter.py @@ -38,7 +38,7 @@ def _do_dock_full_destroy(self) -> None: _log.warning('[决战] 船坞已满,执行自动解装') self._ctrl.click(0.38, 0.565) if not destroy_ships_auto(self._ctx): - # 解装模式为「不启用」, 船坞仍满 - raise DockFullError('决战中船坞已满,且解装模式为「不启用」') + # 白名单覆盖全部舰种, 无可解装对象, 船坞仍满 + raise DockFullError('决战中船坞已满,且无可解装舰种') return raise DockFullError('决战中检测到船坞已满,且未开启 full_destroy') diff --git a/autowsgr/ops/destroy.py b/autowsgr/ops/destroy.py index d1762922..43959bca 100644 --- a/autowsgr/ops/destroy.py +++ b/autowsgr/ops/destroy.py @@ -54,10 +54,12 @@ def destroy_ships( def destroy_ships_auto(ctx: GameContext) -> bool: """按 ``ctx.config`` 的解装设置自动解装。 - 供 normal_fight / event_fight / decisive 船坞满时调用, 统一读取配置: + 供 normal_fight / event_fight / decisive 船坞满时调用, 统一读取配置。 + 是否调用本函数由调用方的 ``dock_full_destroy`` / ``full_destroy`` 开关决定; + 此处只关心「怎么拆」:: - - ``destroy_ship_work_mode == disable``: 不解装, 返回 ``False`` - (调用方据此停止战斗, 视为船坞满)。 + - ``destroy_ship_work_mode == disable``: 不启用舰种分类, 走快速拆解路线 + (``ship_types=None`` → 不打开过滤器, 快速全选解装全部)。 - ``include`` (黑名单): 解装 ``destroy_ship_types`` 指定舰种。 - ``exclude`` (白名单): 解装除 ``destroy_ship_types`` 外的所有舰种。 @@ -66,16 +68,16 @@ def destroy_ships_auto(ctx: GameContext) -> bool: Returns ------- bool - ``True`` 已执行解装; ``False`` 配置为「不启用」。 + ``True`` 已执行解装; ``False`` 仅在白名单覆盖全部舰种、无可解装对象时返回 + (此时船坞仍满, 调用方据此保持 DOCK_FULL)。 """ cfg = ctx.config mode = cfg.destroy_ship_work_mode if mode == DestroyShipWorkMode.disable: - _log.info('[OPS] 解装模式为「不启用」, 跳过自动解装') - return False - - if mode == DestroyShipWorkMode.include: + # 不启用舰种分类: 不过滤, 直接走快速全选拆解路线 + ship_types = None + elif mode == DestroyShipWorkMode.include: ship_types = cfg.destroy_ship_types or None else: # exclude (白名单): 解装除指定舰种外的所有 protected = set(cfg.destroy_ship_types) diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index c32c29be..c4dca59d 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -413,7 +413,7 @@ def _handle_dock_full(self, result: CombatResult) -> None: if destroy_ships_auto(self._ctx): # 解装成功 (调用方可根据需要重试出征) result.flag = ConditionFlag.OPERATION_SUCCESS - # 否则 work_mode=disable, 保持 DOCK_FULL + # 否则无可解装对象 (白名单覆盖全部舰种), 保持 DOCK_FULL return _log.warning('[OPS] 船坞已满, 未开启自动解装') diff --git a/testing/ops/test_destroy_unit.py b/testing/ops/test_destroy_unit.py index 843a2da8..b486d490 100644 --- a/testing/ops/test_destroy_unit.py +++ b/testing/ops/test_destroy_unit.py @@ -48,12 +48,13 @@ def _fake_destroy_ships( return calls -def test_disable_skips_and_returns_false(recorded: list[dict]): +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 False - assert recorded == [] + 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 fb2d59a6cd782eddde93907786c7cc3c20d8aae5 Mon Sep 17 00:00:00 2001 From: YaoerWu Date: Mon, 27 Jul 2026 08:57:53 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E7=A9=BA=E9=97=B2=E4=BF=AE=E7=90=86?= =?UTF-8?q?=E5=9C=A8=E5=B8=B8=E8=A7=84=E6=88=98=E5=AE=8C=E6=88=90=E5=90=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E3=80=81=E6=B7=BB=E5=8A=A0plan=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/combat/plan.py | 4 ++ autowsgr/infra/config.py | 7 ++- autowsgr/infra/config_compat.py | 81 +++++++++++++++++++++++++- autowsgr/ops/repair.py | 4 +- autowsgr/scheduler/daily_plan.py | 38 ++++++++++++- testing/infra/test_config.py | 91 ++++++++++++++++++++++++++++++ testing/ops/test_scheduler_unit.py | 91 ++++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 7 deletions(-) diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 9ddf86a2..ea9c413f 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -271,7 +271,11 @@ def is_selected_node(self, node: str) -> bool: @classmethod def from_yaml(cls, path: str | Path) -> CombatPlan: + from autowsgr.infra.config_compat import migrate_plan_dict + data = load_yaml(path) + # 迁移 classic 计划字段 (如 1-indexed fleet 前导空占位), best-effort 回写原文件 + data = migrate_plan_dict(data, source_path=path) return cls.from_dict(data, name=Path(path).stem) @classmethod diff --git a/autowsgr/infra/config.py b/autowsgr/infra/config.py index 8569ad12..e226d780 100644 --- a/autowsgr/infra/config.py +++ b/autowsgr/infra/config.py @@ -187,7 +187,12 @@ class NormalFightTaskConfig(BaseModel): fleet_id: int | None = None """出征舰队编号; 留空则用 plan 内配置。""" times: int | None = None - """目标出击次数; ``None`` 表示无限 (空闲填充, 仅受全局上限约束)。""" + """目标出击次数; ``None`` 表示无限 (空闲填充, 仅受全局上限约束)。 + + .. note:: ``times=None`` (无限) 且未开启 ``stop_max_ship`` / + ``stop_max_loot`` / ``quick_repair_limit`` 任一上限时, 常规战会持续 + 产出任务、永远抢占浴室修理 (优先级更低), 致浴室修理永不执行。 + """ class DailyAutomationConfig(BaseModel): diff --git a/autowsgr/infra/config_compat.py b/autowsgr/infra/config_compat.py index 4b2c6e52..72eb0e17 100644 --- a/autowsgr/infra/config_compat.py +++ b/autowsgr/infra/config_compat.py @@ -1,8 +1,12 @@ """向下兼容层 — 集中处理用户 YAML 中的废弃 / 迁移字段。 所有 classic 遗留配置的迁移、删除提示、自动映射都集中在本模块, -不分散到各子模型。在 :func:`UserConfig.from_yaml` 的 -``model_validate`` 之前对 raw dict 做一次性预处理。 +不分散到各子模型。两个入口对 raw dict 做一次性预处理: + +- :func:`migrate_raw_config` — 用户配置 (``UserConfig.from_yaml``, + ``model_validate`` 之前); +- :func:`migrate_plan_dict` — 作战计划 (``CombatPlan.from_yaml``, + ``from_dict`` 之前), 目前处理 classic 的 1-indexed ``fleet`` 前导空占位。 设计要点 -------- @@ -16,6 +20,8 @@ :data:`~autowsgr.infra.config.OPERATION_DELAY_MIN` / :data:`~autowsgr.infra.config.OPERATION_DELAY_MAX`, 由 :func:`~autowsgr.infra.config.operation_delay` 运行期读取。 +- ``fleet`` (classic 1-indexed, 首位 ``""`` 占位) 由 + :func:`_migrate_plan_fleet` 剥离前导空占位, 迁移为 dev 0-indexed 格式。 """ from __future__ import annotations @@ -66,6 +72,33 @@ def migrate_raw_config( return data +def migrate_plan_dict( + data: dict[str, Any], + *, + source_path: str | Path | None = None, +) -> dict[str, Any]: + """预处理作战计划 raw dict: 迁移 classic 计划字段。 + + 在 :meth:`autowsgr.combat.plan.CombatPlan.from_yaml` 的 ``from_dict`` + 之前调用。目前处理 classic 的 1-indexed ``fleet`` 前导空占位 + (见 :func:`_migrate_plan_fleet`)。 + + 与 :func:`migrate_raw_config` 同: 原地修改并返回同一对象; 若给出 + *source_path* 且检测到改动, best-effort 写回原文件, 使迁移一次性生效、 + 后续运行不再重复告警。 + """ + if not isinstance(data, dict): + return data + + before = copy.deepcopy(data) + _migrate_plan_fleet(data) + + if source_path is not None and data != before: + _persist(data, source_path) + + return data + + def _persist(data: dict[str, Any], source_path: str | Path) -> None: """把迁移后的 raw dict 写回 *source_path* (best-effort)。 @@ -208,3 +241,47 @@ def _migrate_misc_legacy(data: dict[str, Any]) -> None: '[compat] classic 顶层字段 {} 已废弃 (新版不使用), 已删除。', '/'.join(dropped), ) + + +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 _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/ops/repair.py b/autowsgr/ops/repair.py index 15e06c9d..63056853 100644 --- a/autowsgr/ops/repair.py +++ b/autowsgr/ops/repair.py @@ -90,7 +90,9 @@ def repair_one_available( ) -> bool: """有空闲修理槽时, 派修修理时间最长的非黑名单舰船 (调度入口)。 - 由 ``BathRepairTrigger`` 周期调用。流程: + 由 ``auto_daily`` 调度器以 :class:`~autowsgr.scheduler.triggers.TimerTrigger` + 周期产出, 经优先级队列在所有战斗任务 (战役/演习/常规战) 完成后才执行 + (空闲修船, 见 ``daily_plan.PRIO_BATH_REPAIR``)。流程: 1. ``ctx.bathroom`` 无空闲槽 → 直接返回 (省一次开 overlay)。 2. 导航浴室 → 开选择修理 overlay → 修最长非黑名单船。 diff --git a/autowsgr/scheduler/daily_plan.py b/autowsgr/scheduler/daily_plan.py index 12e0d13a..6d0af417 100644 --- a/autowsgr/scheduler/daily_plan.py +++ b/autowsgr/scheduler/daily_plan.py @@ -5,7 +5,7 @@ 优先级 (数值小先执行, 由 :mod:`autowsgr.scheduler.triggers` 定义):: - 远征 0 < 战役 5 < 演习 10 < 常规战 100 (空闲填充) + 远征 0 < 奖励 1 < 战役 5 < 演习 10 < 常规战 100 < 浴室修理 200 (空闲填充, 最后执行) 使用方式:: @@ -47,10 +47,14 @@ # 优先级常量 (数值越小越先执行) PRIO_EXPEDITION = 0 PRIO_BONUS = 1 -PRIO_BATH_REPAIR = 2 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 分钟轮询一次。 @@ -132,7 +136,7 @@ def build_daily_plan( ), ) - # ── 浴室修理 (定时, prio 2, 完整空位调度 + 黑名单) ── + # ── 浴室修理 (定时, prio 200, 空闲填充: 所有战斗完成后才执行) ── if cfg.auto_bath_repair: from autowsgr.ops.repair import repair_one_available @@ -182,10 +186,38 @@ def build_daily_plan( 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, diff --git a/testing/infra/test_config.py b/testing/infra/test_config.py index aa60e18e..fe0dd372 100644 --- a/testing/infra/test_config.py +++ b/testing/infra/test_config.py @@ -330,6 +330,97 @@ def test_migrate_without_source_path_does_not_persist(self): assert 'emulator_type' not in out +# ── PlanCompat (计划文件迁移: classic fleet 前导空占位) ── + + +class TestPlanCompat: + """计划文件向下兼容迁移: migrate_plan_dict + classic 1-indexed fleet。""" + + 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_plan_fleet_written_back(self, tmp_yaml: Callable[[str, str], Path]): + """CombatPlan.from_yaml 应把迁移结果写回原文件 (前导空剥离)。""" + from autowsgr.combat.plan import CombatPlan + + content = 'fleet: ["", "吹雪", "明斯克", "胡德", "赤诚", ""]\n' + path = tmp_yaml('plan_legacy.yaml', content) + plan = CombatPlan.from_yaml(path) + # 内存: 前导空已剥离 + assert plan.fleet == ['吹雪', '明斯克', '胡德', '赤诚', ''] + # 磁盘: 已回写, fleet 不再有前导空 + rewritten = load_yaml(path) + assert rewritten['fleet'] == ['吹雪', '明斯克', '胡德', '赤诚', ''] + + def test_plan_fleet_writeback_idempotent(self, tmp_yaml: Callable[[str, str], Path]): + """迁移写回后再次加载, 文件不再改动 (一次性生效)。""" + from autowsgr.combat.plan import CombatPlan + + path = tmp_yaml('plan_idem.yaml', 'fleet: ["", "吹雪"]\n') + CombatPlan.from_yaml(path) + first = load_yaml(path) + # 第二次加载: fleet 已无前导空, 无迁移发生, 文件不动 + CombatPlan.from_yaml(path) + assert load_yaml(path) == first + + def test_clean_plan_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') + CombatPlan.from_yaml(path) + assert path.read_text(encoding='utf-8') == before + + # ── LogConfig (setup_logger) ── diff --git a/testing/ops/test_scheduler_unit.py b/testing/ops/test_scheduler_unit.py index 56f05883..dead16a3 100644 --- a/testing/ops/test_scheduler_unit.py +++ b/testing/ops/test_scheduler_unit.py @@ -127,3 +127,94 @@ def test_run_task_single_runner_still_works(monkeypatch: pytest.MonkeyPatch): 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