Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ repos:
hooks:
- id: codespell
additional_dependencies: [".[toml]"]
- repo: local
hooks:
- id: ty-check
name: ty type check
description: Run Astral's ty type checker.
entry: uvx ty check
language: system
pass_filenames: false
require_serial: true
types: [python]
79 changes: 37 additions & 42 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent Guidelines

## 开发环境
## 安装

```bash
git clone git@github.com:OpenWSGR/AutoWSGR.git
Expand All @@ -14,70 +14,65 @@ pre-commit install
```bash
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
pytest
pre-commit run --all-files
```

## 代码风格

- Python 版本:3.12+
- 格式化与 lint:**Ruff**(已覆盖 isort / black 功能),配置见 `pyproject.toml`
- 目标行宽 100,单引号字符串
- 禁止相对导入(`ban-relative-imports = all`)
- 英语拼写检查:**codespell**,忽略词表见 `docs/spelling_wordlist.txt`

提交前务必运行:
## pytest

```bash
pre-commit run --all-files
pytest -n auto
```

## 测试
测试目录结构:

| 目录 | 说明 |
|------|------|
| `tests/unit/` | pytest 自动运行的单元测试 |
| `tests/manual/` | 需真实设备的手动 e2e 测试 |

- 单元测试:`pytest`(测试目录 `testing/`)
- 功能测试:运行 `examples/` 目录中的脚本进行端到端验证
## pre-commit 检查

提交前务必运行:

```bash
pytest
pre-commit run --all-files
```

## 约定式提交(Conventional Commits)
包含 **Ruff**(格式化与 lint)和 **ty**(类型检查)。

提交信息格式:
## 类型检查

```
<type>(<scope>): <简短描述>
本项目使用 **ty**(Astral 出品的 Python 类型检查器)进行静态类型检查。

<正文>
```
- 优先通过正确的类型注解、返回值标注和类型窄化来消除类型错误。
- **禁止**在工作代码中使用 `typing.cast` 来掩盖类型问题;`cast` 只允许在测试文件的 Mock 场景中使用。
- 若类型检查器因容器型变(如 `list` 的 invariant)报错,优先考虑将函数参数改为 `Sequence`、`Mapping` 等协变抽象基类,而非使用 `cast`。
- 修复类型问题时尽量保持最小改动,避免不必要的重构。

常用类型:
### `ty: ignore` 注释规范

- `feat`:新功能
- `fix`:修复
- `build`:构建系统或依赖变更
- `docs`:文档
- `style`:不影响代码逻辑的格式调整
- `refactor`:重构
- `test`:测试
当必须通过注释忽略类型错误时,**必须使用 ty 原生格式**:

示例:
```python
# 正确
c.r = 10 # ty: ignore[invalid-assignment]
ctrl._device.shell.assert_called_once_with('input tap 480 270') # ty: ignore[unresolved-attribute]

```
build: migrate from setuptools to hatchling
# 错误 —— ty 无法识别 mypy 的 error code
# type: ignore[invalid-assignment]
# type: ignore[misc]

- Replace setuptools with hatchling as build backend
- Remove obsolete MANIFEST.in
# 错误 —— 裸 ignore 会被 ruff PGH003 拦截,且无法精确控制
# type: ignore
# type: ignore # noqa: PGH003
```

## 构建与打包
> 项目已启用 `unused-type-ignore-comment = "error"`,未使用的 `# ty: ignore[...]` 会导致 CI 失败。

- Build backend:**hatchling**
- 包数据(图片、YAML、JAR 等)位于 `autowsgr/data/`,由 hatchling 自动包含,无需 `MANIFEST.in`
## 单元测试要求

```bash
uv build
```
新增功能或修改核心逻辑时,必须在 `tests/unit/` 下提供对应的 pytest 单元测试。测试文件应与被测源文件一一对应。

## 约定式提交

## 文档

Expand Down
4 changes: 2 additions & 2 deletions autowsgr/combat/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def __init__(

# 运行时状态 (由 fight() 重置)
self._plan: CombatPlan = CombatPlan(name='', mode=CombatMode.BATTLE)
self._recognizer: CombatRecognizer = None # type: ignore[assignment] # set in fight()
self._recognizer: CombatRecognizer | None = None # set in fight()
self._phase = CombatPhase.PROCEED
self._last_action = 'yes'
self._node = '0'
Expand All @@ -83,7 +83,7 @@ def __init__(
def fight( # noqa: PLR0912
self,
plan: CombatPlan,
initial_ship_stats: list[ShipDamageState],
initial_ship_stats: list[ShipDamageState] | None,
) -> CombatResult:
"""执行一次完整的战斗循环。

Expand Down
4 changes: 3 additions & 1 deletion autowsgr/combat/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ def _handle_spot_enemy(self) -> ConditionFlag: # noqa: PLR0912
# ── 信息采集 ──
mode = 'exercise' if self._plan.mode == CombatMode.EXERCISE else 'fight'
enemies = get_enemy_info(self._device, mode=mode)
enemy_formation = get_enemy_formation(self._device, self._ocr)
enemy_formation = (
get_enemy_formation(self._device, self._ocr) if self._ocr is not None else ''
)
_log.info('[Combat] 敌方编成: {} 阵型: {}', enemies, enemy_formation)

decision = self._get_current_decision()
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/combat/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class CombatEvent:
extra: dict[str, Any] = field(default_factory=dict)

def __str__(self) -> str:
parts = [f'[{self.event_type.name}]']
parts: list[str] = [f'[{self.event_type.name}]']
if self.node:
parts.append(f'节点={self.node}')
if self.action:
Expand Down
12 changes: 8 additions & 4 deletions autowsgr/combat/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@
import re
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any
from typing import TYPE_CHECKING, Any

from autowsgr.infra.logger import get_logger
from autowsgr.types import Formation


if TYPE_CHECKING:
from collections.abc import Mapping


# 允许在规则中出现的舰种标识符
_log = get_logger('combat.recognition')

Expand Down Expand Up @@ -134,7 +138,7 @@ def __post_init__(self) -> None:
if self.op not in _OPERATORS:
raise ValueError(f"不支持的操作符: '{self.op}',支持: {list(_OPERATORS)}")

def evaluate(self, context: dict[str, int | float]) -> bool:
def evaluate(self, context: Mapping[str, int | float]) -> bool:
"""在给定上下文中评估此条件。"""
if '+' in self.field:
parts = [p.strip() for p in self.field.split('+')]
Expand All @@ -159,7 +163,7 @@ class Rule:
conditions: list[Condition]
action: RuleAction

def evaluate(self, context: dict[str, int | float]) -> bool:
def evaluate(self, context: Mapping[str, int | float]) -> bool:
"""所有条件是否均满足。"""
return all(c.evaluate(context) for c in self.conditions)

Expand All @@ -184,7 +188,7 @@ class RuleEngine:
rules: list[Rule] = field(default_factory=list)
default: RuleAction = field(default_factory=RuleAction.no_action)

def evaluate(self, context: dict[str, int | float]) -> RuleAction:
def evaluate(self, context: Mapping[str, int | float]) -> RuleAction:
"""对敌方编成上下文评估所有规则。

Parameters
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/context/game_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class GameContext:

# ── 基础设施引用 (可选) ──

ocr: OCREngine
ocr: OCREngine | None = None
"""OCR 引擎实例 (章节/阵型识别等)。"""

# ── 游戏运行时状态 ──
Expand Down
1 change: 1 addition & 0 deletions autowsgr/emulator/os_control/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def stop(self) -> None:
_log.info('云手机无需关闭')
return
case _:
assert self._process_name is not None
subprocess.run( # noqa: S603
['taskkill', '-f', '-im', self._process_name], # noqa: S607
check=True,
Expand Down
2 changes: 2 additions & 0 deletions autowsgr/image_resources/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ def _build_map() -> dict[TemplateKey, list[ImageTemplate]]:
TemplateKey.END_EXERCISE_PAGE: [T.END_EXERCISE_PAGE],
# 船坞已满
TemplateKey.DOCK_FULL: [T.DOCK_FULL],
# 战役次数耗尽
TemplateKey.BATTLE_TIMES_EXCEED: [T.BATTLE_TIMES_EXCEED],
# 战果评级
TemplateKey.GRADE_SS: [T.Result.SS],
TemplateKey.GRADE_S: [T.Result.S],
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/infra/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ def load(path: str | Path | None = None) -> UserConfig:
return UserConfig()
except ValidationError:
# WSL/Linux 下默认配置缺少 serial/path 无法通过验证,提供占位值
return UserConfig(emulator={'serial': '', 'path': ''})
return UserConfig(emulator=EmulatorConfig(serial='', path=''))
config = UserConfig.from_yaml(path)
_log.info('已加载配置: {}', path)
return config
Expand Down
8 changes: 4 additions & 4 deletions autowsgr/infra/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
from collections.abc import Callable

import numpy as np
from loguru import Logger
from loguru import Logger, Record


# ═══════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -103,7 +103,7 @@
# ═══════════════════════════════════════════════════════════════════════════════


def _src_patcher(record: dict) -> None:
def _src_patcher(record: Record) -> None:
"""将 record["file"].path 转为以项目根目录为基准的相对路径,并存入 extra["src"]。

格式示例:``emulator/controller.py:346``
Expand Down Expand Up @@ -382,11 +382,11 @@ def save_image(
保存的文件路径,未保存时返回 None。
"""

target_dir = str(img_dir or _image_dir)
target_dir = Path(target_dir)
target_dir = img_dir or _image_dir
if target_dir is None:
raise ValueError('未配置图片保存目录,请在 setup_logger 中设置 log_dir 并启用 save_images')

target_dir = Path(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
ts = _time.strftime('%H%M%S') + f'_{int(_time.monotonic() * 1000) % 1000:03d}'
filename = f'{tag}_{ts}.png'
Expand Down
4 changes: 2 additions & 2 deletions autowsgr/ops/decisive/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ def _handle_use_last_fleet(self) -> None:
def _handle_dock_full(self) -> None:
"""船坞已满: 自动解装 → ENTER_MAP。"""
_log.warning('[决战] 处理船坞已满')
self._do_dock_full_destroy() # type: ignore[attr-defined] # from DecisiveChapterOps
self._prepare_entry_state() # type: ignore[attr-defined] # from DecisiveChapterOps
self._do_dock_full_destroy() # from DecisiveChapterOps
self._prepare_entry_state() # from DecisiveChapterOps
self._state.phase = DecisivePhase.ENTER_MAP

# ── 舰队与地图 ────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/ops/decisive/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class DecisiveState:
def reset(self) -> None:
"""重置状态 (保留 chapter)。"""
chapter = self.chapter
self.__init__() # type: ignore[misc]
self.__init__()
self.chapter = chapter

def is_begin(self) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/ops/event_fight.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def _enter_fight(self) -> None:

# 委托 UI 层完成: 难度 / 节点 / 出击
event_page = BaseEventPage(self._ctx)
entrance: Literal['alpha', 'beta'] | None = self._entrance # type: ignore[assignment]
entrance: Literal['alpha', 'beta'] | None = self._entrance
event_page.start_fight(self._map_code, entrance, self._skip_check)

# ── 出征准备 ──
Expand Down
1 change: 1 addition & 0 deletions autowsgr/scheduler/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def build_context(self) -> GameContext:
"""
if self._ocr is None:
self.create_ocr()
assert self._ocr is not None

ctx = GameContext(
ctrl=self.ctrl,
Expand Down
8 changes: 4 additions & 4 deletions autowsgr/scheduler/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from autowsgr.combat import CombatResult
from autowsgr.infra.logger import get_logger
Expand Down Expand Up @@ -64,13 +64,13 @@ class BatchRunnerAdapter:
每次 ``.run()`` 返回最后一场结果;若列表为空,返回默认成功。
"""

def __init__(self, inner: object) -> None:
def __init__(self, inner: Any) -> None:
if not hasattr(inner, 'run'):
raise TypeError(f'{type(inner).__name__} 没有 run() 方法')
self._inner = inner

def run(self) -> CombatResult:
results = self._inner.run() # type: ignore[union-attr]
results = self._inner.run()
if isinstance(results, list):
return (
results[-1]
Expand All @@ -79,7 +79,7 @@ def run(self) -> CombatResult:
flag=ConditionFlag.OPERATION_SUCCESS,
)
)
return results # type: ignore[return-value]
return results


# ═══════════════════════════════════════════════════════════════════════════════
Expand Down
2 changes: 1 addition & 1 deletion autowsgr/server/routes/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@


@router.post('/start', response_model=ApiResponse)
async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[arg-type]
async def task_start(request: TaskRequestUnion) -> ApiResponse:
"""启动任务 (异步执行,立即返回)。"""
if task_manager.is_running:
raise HTTPException(status_code=409, detail='已有任务正在运行')
Expand Down
8 changes: 5 additions & 3 deletions autowsgr/ui/battle/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@


if TYPE_CHECKING:
from collections.abc import Sequence

import numpy as np

from autowsgr.context.ship import Ship
Expand All @@ -49,7 +51,7 @@ class FleetInfo:
ship_damage: dict[int, ShipDamageState] = field(default_factory=dict)
"""槽位号 (0-5) → 血量状态。"""

def to_ships(self, names: list[str | None] | None = None) -> list[Ship]:
def to_ships(self, names: Sequence[str | None] | None = None) -> list[Ship]:
"""将舰队信息转换为 Ship 列表。

Parameters
Expand All @@ -71,8 +73,8 @@ def to_ships(self, names: list[str | None] | None = None) -> list[Ship]:
if damage == ShipDamageState.NO_SHIP:
continue
name = ''
if names and i < len(names) and names[i] is not None:
name = names[i]
if names and i < len(names):
name = names[i] or ''
ships.append(
Ship(
name=name,
Expand Down
Loading
Loading