diff --git a/.claude/skills b/.claude/skills index 2b7a412b..9f020f7e 120000 --- a/.claude/skills +++ b/.claude/skills @@ -1 +1 @@ -../.agents/skills \ No newline at end of file +../.agents/skills diff --git a/.gitignore b/.gitignore index b8b9252e..c3e9ff44 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,22 @@ Cargo.lock # Agent temporary files .tmp/ +# Agent debug toolkit keeps empty output directories but ignores generated files. +!tools/debug_toolkit/ +!tools/debug_toolkit/result/ +!tools/debug_toolkit/result/logs/ +!tools/debug_toolkit/result/logs/.gitkeep +!tools/debug_toolkit/result/screenshot/ +!tools/debug_toolkit/result/screenshot/.gitkeep +!tools/debug_toolkit/result/ocr/ +!tools/debug_toolkit/result/ocr/.gitkeep +tools/debug_toolkit/result/logs/* +!tools/debug_toolkit/result/logs/.gitkeep +tools/debug_toolkit/result/screenshot/* +!tools/debug_toolkit/result/screenshot/.gitkeep +tools/debug_toolkit/result/ocr/* +!tools/debug_toolkit/result/ocr/.gitkeep + # MCP / Jupyter 测试产物 test_*.ipynb *_test.ipynb diff --git a/AGENTS.md b/AGENTS.md index 50fb5255..1eb7e896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -# Agent Guidelines +# AutoWSGR Agent 约束 + +本文件是所有人工辅助 Agent、自动化 Agent、代码生成/审查工具和 Issue 分析工具的项目入口。用户明确要求优先于本文件;所有分支和发布场景均执行同一套 Coding、质量和协作门禁。 ## 开发环境 @@ -18,30 +20,115 @@ pytest pre-commit run --all-files ``` -## 代码风格 +## 1. Coding 最高原则 + +### 1.1 四大原则 + +每一次代码改动,无论大小,都必须同时满足: + +1. **最小改动**:只修改实现目标所需的代码、契约和测试,不顺手重构、格式化、清理无关内容,也不新增非必要文件、类、函数、依赖或分支。 +2. **最大复用**:优先复用职责、生命周期、输入输出和副作用均匹配的现有模块、函数、常量、测试工具与数据契约;不得为了表面复用扩大原模块职责,也不得复制粘贴现有实现。 +3. **最低影响现有功能**:既有业务路径、公共 API、配置/YAML 契约、数据模型、依赖方向和已发布行为默认保持不变。 +4. **严格控制代码边界**:改动必须留在清晰的功能和架构边界内,遵循当前目录、命名和依赖规则,不得引入跨层补偿、重复状态源、隐式耦合或自创实现范式。 + +业务逻辑正确性和现有行为兼容性为第一优先级。用户要求修改功能 A,不代表自动授权改变关联业务规则或功能 B;若根因修复必须改变默认行为、数据含义、公共契约或其他功能,写入前必须说明现状、拟议变化、影响范围和验证方式,并取得用户确认。 + +### 1.2 分批改动 + +- 涉及多个独立功能、多个架构边界或大量手写文件时,必须先给出分批方案,经用户确认后实施。 +- 每一批只解决一个可描述、可验证、可审查、可回滚的行为边界,并独立遵守四大原则。 +- 每批完成后先检查 diff、运行匹配测试并确认没有相邻回归,再开始下一批。 +- 不得先批量搬迁或重写,再依靠后续批次恢复功能;任何中间批次都不能故意处于已知损坏状态。 +- 机械生成内容可随对应源文件更新,但必须与手写行为代码分开说明。 + +### 1.3 功能边界与防回归 + +- 修改前必须搜索目标功能的调用方、导入方、事件入口、公共 API、配置/YAML Schema、数据模型、持久化字段和测试,识别依赖其实现或契约的其他功能。 +- 修复局部问题不得改变全局调度、状态、重试、持久化或错误处理语义;公共实现必须变化时,先建立兼容边界或同步修改并验证全部消费者。 +- 状态只能有一个权威所有者;异步任务、缓存、重试和重新初始化不得意外重置、复制或竞争状态。 +- Bug 修复必须提供修复前可失败、修复后可通过的复现证据,不能只证明目标路径“现在能跑”。 +- 前后端契约共同变化时,必须检查 AutoWSGR-GUI 中对应的 API、DTO、Adapter 和跨仓契约测试,不得只验证单边实现。 +- 修改双方共享的 API 请求、配置 Schema、任务类型、DTO 或业务契约前,必须说明所有权、双方调用方、合并影响和验证方式,并取得用户确认。 + +## 2. 接入与事实来源 + +开始写入前必须: + +1. 执行 `git status --short --branch`,识别当前分支和用户已有修改;不得覆盖、回退、暂存或整理非当前任务内容。 +2. 按任务读取 `pyproject.toml`、构建脚本、CI、受影响源码、直接调用方、相关文档和最近的专项测试。 +3. 说明行为目标、非目标、可复用实现、最少修改文件、状态所有者、功能消费者、风险和验证计划。 +4. 搜索同领域规则、历史 workaround 和兼容契约;第三次修复或重复回归必须先分析此前失败原因,不能继续叠加 guard、retry、delay 或 fallback。 + +事实与约束分开判断: + +- 用户明确要求最高;本文件是后端 Coding、质量和协作流程的唯一规范入口。 +- 当前目录、依赖、命令和运行行为以可执行配置、CI、测试和生产源码为准,不得用旧分支或旧文档反向改造当前实现。 +- 后端模型、配置和公开接口是业务事实来源;GUI 只能消费后端已支持的能力,不得反向要求后端迁就未经确认的前端自定义规则。 +- 后端架构以本仓库源码和后端文档为准,不复制 GUI 的 Electron/Renderer 分层;发现文档与实现冲突时先报告并判断原因,不得静默选择一方。 + +### 2.1 修复止损 + +- 声称修复后问题仍可复现、验证失败、需要绕过上次错误假设,或保留旧 workaround 后再增加特殊处理,均算一次失败尝试;纯诊断日志和不改变行为的 instrumentation 不计入。 + +| 等级 | 触发条件 | 必须执行的动作 | +|---|---|---| +| L0 正常变更 | 无失败尝试、无意外扩散、有直接证据 | 正常实现和审查 | +| L1 记录修正 | 一次失败,或出现一个止损信号 | 记录原假设、失败证据、状态所有者和新验证计划 | +| L2 维护者检查点 | 两次连续失败,或同时出现两个止损信号 | 暂停实现;用户批准重新设计、拆分或干净重写后才能继续 | +| L3 Patch Freeze | 三次失败且仍有止损信号,或出现竞争状态源 | 禁止叠加补丁,先建立确定性复现和替代设计 | +| L4 干净重写 | 无法删除失败 workaround、恢复单一状态源或证明端到端行为 | 记录最后正常基线;经用户明确批准后,在隔离分支/worktree 中固定契约并重新实现 | + +- 止损信号包括范围外跨层补偿、新可写状态/同步标志/影子缓存、规则重复、retry/delay/catch-and-ignore/多级 fallback、放宽类型或测试,以及无法解释完整因果链。 +- 同一问题的失败次数跨 Agent、会话、分支和实现方案累计,不能通过换人、换文件或改名重置。 +- 第三次尝试前必须说明前两次为什么失败、原因链如何变化、新证据如何区分假设;没有新因果模型不得继续补 guard、retry、delay 或 fallback。 +- 修改中出现新状态源、跨层补偿、公共契约变化或范围升级时,立即重新评估 Patch Level,并按更高等级执行。 +- L4 不得复制失败分支或覆盖用户工作树;必须记录正常基线 SHA/Tag,仅迁移仍被当前契约和测试证明需要的行为,并保留旧失败补丁供只读对照。 + +## 3. 代码风格 - Python 版本:3.12+ - 格式化与 lint:**Ruff**(已覆盖 isort / black 功能),配置见 `pyproject.toml` - 目标行宽 100,单引号字符串 - 禁止相对导入(`ban-relative-imports = all`) - 英语拼写检查:**codespell**,忽略词表见 `docs/spelling_wordlist.txt` - -提交前务必运行: - -```bash -pre-commit run --all-files -``` - -## 测试 - -- 单元测试:`pytest`(测试目录 `testing/`) -- 功能测试:运行 `examples/` 目录中的脚本进行端到端验证 +- 使用 UTF-8、LF 和文件末尾换行;Python 使用 4 空格,Markdown/YAML 使用项目现有格式。 + +## 4. 实现门禁 + +- 能修改现有实现时不得新增重复模块、包装层、状态源或兼容分支;每个新增文件、函数、类型、依赖和缓存都必须说明必要性。 +- 新抽象必须有当前真实调用方,并能减少实质重复或隔离明确边界;不得为“以后可能扩展”预留空接口、Manager、Factory、Registry 或 EventBus。 +- 注释和 docstring 解释算法、设计原因、限制和兼容背景,不复述代码;临时 workaround 必须说明触发条件、移除条件和对应测试。 +- 防御式代码只放在真实外部边界或已验证失败路径;不得用宽泛 `try/except`、静默降级和多层 fallback 掩盖状态所有权或契约错误。 +- 为测试新增注入点时优先使用构造参数或显式依赖,不得把调试开关、测试状态或仅测试使用的 API 暴露到生产路径。 +- 发现范围扩大、新状态源、跨层补偿或依赖功能回归时立即停止,重新说明范围并取得用户确认。 +- 不得关闭 SSL、路径、类型、Schema、测试、签名或权限校验来绕过问题。 + +### 4.1 高风险不变量 + +- 包内资源保持只读;用户配置、计划、迁移状态和运行数据写入项目既有数据目录,不得回写安装或包资源目录。 +- 外部文件入口必须先完成路径规范化和允许目录包含性检查,不得通过通用接口读写任意绝对路径。 +- 用户数据使用原子写入,替换失败必须保留旧文件;迁移只能在全部文件成功写入后记录阶段完成。 +- 已发布格式、目录、模板 ID、任务索引、配置默认值和未知字段属于兼容契约,不得静默丢弃或覆盖。 +- 强化、解装、购买等不可逆或消耗资源的操作默认关闭,必须由用户明确确认;目标、状态或确认不确定时 fail closed。 +- 不得泄露或提交密钥、Token、用户配置、含隐私日志、运行时数据或本地环境文件。 + +## 5. 测试与验证 + +- 所有行为修改都需要确定性验证;构建成功或一次手工运行不能替代匹配目标行为的专项测试。 +- 单元测试使用 `pytest`,测试目录为 `testing/`;已有专项测试按改动风险选择直接相关测试和相邻回归测试,不得只运行最容易通过的测试。 +- 功能测试运行 `examples/` 中与目标功能对应的脚本;涉及真实游戏执行链路时还须通过模拟器或实机验证。 +- 修改公共 API、配置 Schema 或前后端契约时,必须验证全部消费者和对应跨仓契约;无法验证时明确列出未验证路径。 +- 涉及共享状态、进程、端口、设备、缓存或临时目录的测试默认串行执行。 +- 提交前运行 `pre-commit run --all-files`;若工作区含用户未提交修改且该命令可能改写无关文件,应改为仅检查本批文件并在交付说明中记录未执行的全量检查。 +- 交付前执行 `git diff --check`,确认 diff 只包含本批任务文件,并记录测试命令、结果、失败尝试和剩余风险。 +- 后端发布至少执行 `pytest -q`、`pre-commit run --all-files`、`uv build` 和 `git diff --check`;无法执行的检查必须明确记录,不得宣称发布可用。 ```bash pytest +pre-commit run --all-files ``` -## 约定式提交(Conventional Commits) +## 6. 约定式提交(Conventional Commits) 提交信息格式: @@ -70,36 +157,68 @@ build: migrate from setuptools to hatchling - Remove obsolete MANIFEST.in ``` -## 构建与打包 +## 7. 构建与打包 - Build backend:**hatchling** - 包数据(图片、YAML、JAR 等)位于 `autowsgr/data/`,由 hatchling 自动包含,无需 `MANIFEST.in` +- 生成文件不得手工修改;应修改对应源文件并运行项目既有生成命令,提交需要跟踪的生成结果。 +- 依赖锁文件只能随明确依赖变更更新;大型资源、fixture 和机械生成内容必须与手写行为代码分开说明。 ```bash uv build ``` -## 文档 +## 8. 文档 - 用户文档地址:https://docs-autowsgr.notion.site -- 代码变更后同步更新文档,并鼓励在代码中编写注释和文档字符串。 - -## ShiinaKuroko Fork 分支管理 - -本仓库的个人 Fork 为 `https://github.com/ShiinaKuroko/AutoWSGR.git`。后续 Agent -必须遵守以下分支职责,不得自行改变分支用途: - -- `main` 只用于同步 `OpenWSGR/AutoWSGR:main`,禁止在该分支直接开发、提交或推送功能代码。 -- `ShiinaKuroko` 是个人 Fork 的最新开发分支,经过验证的最新代码才允许推送到这里。 -- `backup/YYYYMMDD-` 是版本备份分支。每次更新 `ShiinaKuroko` 前,先创建一个指向更新前稳定提交的备份;完成更新后,再创建一个指向新稳定提交的备份,最多保留两个备份分支。 -- 备份分支一旦创建不得移动、覆盖或追加提交。超过两个备份时,只删除最旧的备份分支,不删除当前备份和上一个备份。 -- Agent 临时分支、worktree 分支和实验分支不得直接推送到 `main` 或冒充 `ShiinaKuroko`;任务完成后应删除不再需要的临时远程分支。 -- 推送前必须确认工作树、提交范围和目标分支:`git status --short --branch`、`git diff --check`、`git log --oneline -5`。 -- 推送最新代码前必须先创建备份,并使用 `git push --force-with-lease` 更新 `ShiinaKuroko`,禁止无条件 `--force`。 -- 任何删除远程分支的操作都必须先列出将被删除的分支、提交和原因;禁止删除 `main`、`ShiinaKuroko` 或未明确授权的分支。 -- 新功能必须在独立分支或 worktree 中开发,完成测试后才能合并或推送到 `ShiinaKuroko`。 -- 本地独立开发分支只能用于编码、测试和审查,禁止直接推送到 Fork 的任何发布分支。 -- 本地独立分支完成后,必须将已验证提交合并、cherry-pick 或 rebase 整理到本地 `ShiinaKuroko` 分支;只有本地 `ShiinaKuroko` 分支允许执行 `git push origin ShiinaKuroko`。 -- 不得执行 `git push origin ` 作为发布流程;远程临时分支如确有协作需要,必须获得明确授权,并不得替代 `ShiinaKuroko` 发布入口。 -- 推送前必须确认当前分支为本地 `ShiinaKuroko`,且 `git log origin/ShiinaKuroko..ShiinaKuroko` 只包含本次计划发布的提交。 -- 后端发布至少执行 `pytest -q` 和 `git diff --check`;无法执行的检查必须在交付说明中明确记录。 +- 功能文档存放在 `docs/features/`;不得把阶段性说明或任务文档散落在仓库根目录。 +- 代码行为、公共 API、配置 Schema、架构或协作职责变化时,同一批更新对应文档;仅实现细节变化时不制造无价值文档 churn。 +- 发现文档与实现不一致时先报告并判断是实现偏离还是文档过期,不得静默选择一方。 +- 文档、注释和 docstring 应说明用户可见行为、输入输出、边界条件和设计原因,避免只复述代码。 + +## 9. Git 与发布 + +### 9.1 工作区和提交 + +- 不得覆盖、回退、删除、暂存或格式化用户已有修改;发现无关修改时忽略,只有确实妨碍任务时才请求处理方式。 +- 未经明确要求,不得执行 `git reset --hard`、`git checkout -- `、强制清理、历史重写、`--no-verify` 或任何形式的强推。 +- 不得使用 `git add .`;按逻辑变更显式暂存。经批准创建的分支使用语义前缀,一个 commit 对应一个可独立审查的逻辑变更,提交信息遵循第 6 节。 + +### 9.2 ShiinaKuroko Fork + +- 本仓库的个人 Fork 为 `https://github.com/ShiinaKuroko/AutoWSGR.git`;路径以仓库为基准,不得固化某台机器的绝对路径。 +- `main` 只用于同步 `OpenWSGR/AutoWSGR:alpha`,禁止在该分支直接开发、提交或推送功能代码;同步上游 `alpha` 后再更新个人 Fork 的 `origin/main`。 +- `ShiinaKuroko` 是个人 Fork 的主力开发和发布分支;经过验证的代码才允许从该分支执行普通 push。 +- 未经维护者事先明确允许,不得创建任何本地或远程分支、备份分支、worktree 或发布克隆;不得以隔离脏工作树、备份、测试、打包或发布为理由自行创建。 +- 只有维护者明确要求并批准 PR 时,才允许创建对应 PR 分支;格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`,Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 +- 每次 push 前以当前 `origin/ShiinaKuroko` 为指针创建不可移动备份 `backup/YYYYMMDD-`;备份仅保留本次 push 和上一次 push的两个,创建新备份后删除更早的旧备份。不得擅自移动、复用或删除 backup 分支,但按本条保留策略执行的删除除外。 +- GitHub 网络操作前读取系统当前代理设置;只允许使用命令级临时代理,不得硬编码代理地址、端口或修改全局 Git 配置。 +- 代理未启用或端口未监听时,允许使用正常直连路径;连接失败时应区分网络、认证和远端冲突问题。 +- 每次 push `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 +- 普通 push 禁止 `--force` 和 `--force-with-lease`。只有用户明确授权历史改写、已说明将被替换的远端提交且备份完成时,才允许使用 `--force-with-lease`。 +- push 前必须确认当前分支、工作树、提交范围和目标分支:`git status --short --branch`、`git diff --check`、`git log --oneline -5` 和 `git log origin/ShiinaKuroko..ShiinaKuroko`。 +- push 完成后检查其他本地工作分支:只有确认有效提交已进入 `ShiinaKuroko`、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 +- 删除、重命名或强制移动本地分支前,必须检查 `git config --local --get branch.<分支名>.agentProtected`;值为 `true` 的分支是用户保留分支,排除在所有 Agent 分支清理范围之外。即使该分支已合入、没有远端跟踪或未被 worktree 使用也不得处理,只有用户明确要求时才能移除保护标记或分支。 + +### 9.3 发布 + +- 预发布版本、个人 Fork 或开发频道不是质量豁免;四大原则、测试门禁、数据安全和兼容要求全部生效。 +- 只有用户明确要求时,才能修改版本、创建 Tag、触发发布或上传产物。 +- 发布提交只包含版本与发布元数据;版本、Tag 和构建产物必须一致,不得覆盖已有远程 Tag。 +- 发布前必须完成第 5 节规定的验证、构建和产物检查;无法完成时不得宣称发布可用。 + +## 10. 规范维护 + +- 本文件维护长期 Coding、质量、文档、Git 和协作门禁;后端架构与具体业务规则由本仓库对应源码和专项文档维护。 +- GUI 与后端共享开发范式,但各自读取并遵守本仓库的架构、技术栈、构建和测试要求,不得把 GUI 的 Electron/Renderer 结构套用到后端。 +- `.github/workflows/**` 定义实际自动化;修改前必须说明权限、Secret、触发条件和发布影响。 +- 工具入口只指向本文件,不复制整套规则;阶段性迁移清单、历史事故和已结束的单次分工放入对应任务文档,不写入长期 Agent 约束。 +- 修改本文件前必须先审查当前代码和门禁;新增、删除或降低规则前,先向用户列明具体动作、原因和影响并取得确认。 + +## 11. Project Worktree Coordination + +When this repository is inside a parent project containing a coordination `AGENTS.md`, read the parent policy before writing. The parent meta repository owns the `planning-with-files` task files; this repository owns only backend code, tests, and backend-specific release artifacts. + +- The current project policy explicitly authorizes local task branches and worktrees for parallel work. This does not authorize remote branch, push, tag, or release operations. +- Work only in the child worktree assigned to the matching `codex/` branch. Do not switch the shared `ShiinaKuroko` or `main` checkout to another task branch. +- Keep backend commits and verification in this repository, and record the resulting SHA in the parent meta task's `progress.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d..c3170642 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/autowsgr/combat/__init__.py b/autowsgr/combat/__init__.py index 4eed78be..63d2ef2f 100644 --- a/autowsgr/combat/__init__.py +++ b/autowsgr/combat/__init__.py @@ -11,9 +11,9 @@ fleet_slot_from_api, resolve_fleet_selection, ) -from .history import CombatEvent, CombatHistory, CombatResult, FightResult +from .history import CombatEvent, CombatHistory, CombatResult, FightResult, grade_condition_met from .node_tracker import MapNodeData, NodeTracker -from .plan import CombatMode, CombatPlan, NodeDecision +from .plan import CombatMode, CombatPlan, GradeCondition, NodeDecision from .recognition import ( SHIP_DROP_PAGE_SIGNATURE, ShipDropResult, @@ -38,6 +38,7 @@ 'FleetPreset', 'FleetSelectionSource', 'FleetSlotRule', + 'GradeCondition', 'MapNodeData', 'NodeDecision', 'NodeTracker', @@ -47,6 +48,7 @@ 'ShipDropResult', 'ShipSelector', 'fleet_slot_from_api', + 'grade_condition_met', 'recognize_enemy_formation', 'recognize_ship_drop', 'resolve_fleet_selection', diff --git a/autowsgr/combat/actions.py b/autowsgr/combat/actions.py index 01309e90..a35012cc 100644 --- a/autowsgr/combat/actions.py +++ b/autowsgr/combat/actions.py @@ -89,11 +89,6 @@ class Coords: # ═══════════════════════════════════════════════════════════════════════════════ -def click_start_march(device: AndroidController) -> None: - """点击出征按钮。""" - device.click(*Coords.START_MARCH) - - def click_retreat(device: AndroidController) -> None: """点击撤退按钮(索敌成功界面)。""" device.click(*Coords.RETREAT) @@ -319,11 +314,17 @@ def get_ship_drop(device: AndroidController, ocr: OCREngine) -> str | None: 掉落的舰船名称,或 ``None`` 如果未获取到。 """ - screen = device.screenshot() - result = recognize_ship_drop(screen, ocr) - if result.ship_name: - _log.info('[Combat] 掉落识别: {} ({})', result.ship_name, result.ship_type or '未知') - return result.ship_name + # 页面动画可能让首帧尚未完成; 保留有限重试后再交给调用方点击离开。 + for attempt in range(5): + if attempt: + time.sleep(0.5) + screen = device.screenshot() + result = recognize_ship_drop(screen, ocr) + if result.ship_name: + _log.info('[Combat] 掉落识别: {} ({})', result.ship_name, result.ship_type or '未知') + return result.ship_name + _log.debug('[Combat] 掉落 OCR 第 {}/5 次未识别', attempt + 1) + return None # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py index 5da60a05..2a720f8d 100644 --- a/autowsgr/combat/fleet.py +++ b/autowsgr/combat/fleet.py @@ -25,9 +25,6 @@ from autowsgr.combat.plan import CombatPlan -NATIVE_FLEET_VESSEL_TYPES = tuple(vessel_type.native for vessel_type in FLEET_VESSEL_TYPES) -"""由公共 native 契约提供的普通舰种。""" - _NATIVE_CODE_TO_SHIP_TYPE: Mapping[str, ShipType] = MappingProxyType( { 'cv': ShipType.CV, diff --git a/autowsgr/combat/handlers.py b/autowsgr/combat/handlers.py index 0e3516bc..63b359c7 100644 --- a/autowsgr/combat/handlers.py +++ b/autowsgr/combat/handlers.py @@ -11,6 +11,8 @@ import time from typing import TYPE_CHECKING +import numpy as np + from autowsgr.combat.actions import ( check_blood, click_enter_fight, @@ -29,7 +31,15 @@ get_ship_drop, image_exist, ) -from autowsgr.combat.recognition import detect_mvp +from autowsgr.combat.recognition import ( + SHIP_DROP_PAGE_SIGNATURE, + detect_mvp, +) +from autowsgr.combat.recognizer import ( + CombatRecognizer, + EXP_SETTLEMENT_TIMEOUT_SECONDS, + EXP_SETTLEMENT_STABLE_SECONDS, +) from autowsgr.image_resources import TemplateKey from autowsgr.infra.logger import get_logger from autowsgr.types import ConditionFlag, Formation, ShipDamageState @@ -60,6 +70,7 @@ CombatPhase.FIGHT_PERIOD: '_handle_fight_period', CombatPhase.NIGHT_PROMPT: '_handle_night_prompt', CombatPhase.RESULT: '_handle_result', + CombatPhase.EXP_SETTLEMENT: '_handle_exp_settlement', CombatPhase.GET_SHIP: '_handle_get_ship', CombatPhase.PROCEED: '_handle_proceed', CombatPhase.FLAGSHIP_SEVERE_DAMAGE: '_handle_flagship_severe_damage', @@ -83,6 +94,7 @@ class PhaseHandlersMixin: _history: CombatHistory _node_count: int _formation_by_rule: Formation | None + _recognizer: CombatRecognizer """ # 类型提示 (供 IDE/mypy 在 Mixin 上下文使用) @@ -93,6 +105,7 @@ class PhaseHandlersMixin: _last_action: str _ship_stats: list[ShipDamageState] _history: CombatHistory + _recognizer: CombatRecognizer _node_count: int _formation_by_rule: Formation | None @@ -381,12 +394,22 @@ def _handle_night_prompt(self) -> ConditionFlag: return ConditionFlag.FIGHT_CONTINUE def _handle_result(self) -> ConditionFlag: - """处理战果结算 -- 识别评级、更新血量、MVP、关闭界面。""" - # ── 信息采集 ── + """处理战果结算 -- 采集评级/MVP/血量并关闭界面。 + + 始终采集完整战果信息 (评级/MVP/血量): 触发器计数、条件战斗判定、 + 掉落统计均依赖 FightResult 数据, 快速模式不采集会导致计数器不工作。 + + ``collect_result_info`` 仅影响页面通过策略: + 慢速 (有战果条件): 经验页入状态机逐页推进; + 快速 (默认): 经验页是过渡页, 点击直接穿行。 + """ + # 硬等待: RESULT 页面动画 (结算界面滑入/文字淡入) 需要时间稳定, + # 远端卡顿机器上立即 OCR 会截到半渲染画面导致血量/评级/MVP 识别失败 + time.sleep(1.5) + + # ── 信息采集 (始终采集: 计数器/触发器/掉落统计依赖) ── grade = detect_result_grade(self._device) self._ship_stats = detect_ship_stats(self._device, self._ship_stats) - - # MVP 识别 (在关闭结算界面之前) screen = self._device.screenshot() mvp = detect_mvp(screen) @@ -408,27 +431,267 @@ def _handle_result(self) -> ConditionFlag: _log.info('[Combat] 战果: {} 节点: {}', fight_result, self._node) # ── 关闭结算界面 ── - time.sleep(1) - click_result(self._device) - time.sleep(0.25) - click_result(self._device) + if self._plan.collect_result_info: + # 慢速: 经验页入状态机逐页推进 + self._click_result_until_closed(CombatPhase.RESULT) + else: + # 快速: 经验页是过渡页, 点击直接穿行 + self._click_result_until_closed( + CombatPhase.RESULT, + pass_through=(CombatPhase.EXP_SETTLEMENT,), + ) + return ConditionFlag.FIGHT_CONTINUE + + def _wait_for_exp_settlement(self) -> None: + """Accumulate incremental OCR tokens until ``digits + EXP`` is stable.""" + signature = self._recognizer.get_signature(CombatPhase.EXP_SETTLEMENT) + interval = 0.75 + started_at = time.monotonic() + deadline = started_at + EXP_SETTLEMENT_TIMEOUT_SECONDS + completed_results: list[str] = [] + current_digits = '' + current_exp_tokens: list[str] = [] + + while time.monotonic() < deadline: + text = self._recognizer.recognize_exp_settlement_text( + self._device.screenshot() + ) + if text: + digits = ''.join(char for char in text if char.isdigit()) + if digits: + if not current_digits or current_digits in digits: + current_digits = digits + elif digits not in current_digits: + current_digits = digits + + for char in text: + if char == 'E' and not current_exp_tokens: + current_exp_tokens.append(char) + elif char == 'X' and current_exp_tokens == ['E']: + current_exp_tokens.append(char) + elif char == 'P' and current_exp_tokens == ['E', 'X']: + current_exp_tokens.append(char) + + if current_digits and current_exp_tokens == ['E', 'X', 'P']: + completed_results.append(current_digits) + _log.debug( + '[Combat] 经验结算结果累加: {} ({}/3)', + current_digits, + len(completed_results), + ) + current_digits = '' + current_exp_tokens = [] + + elapsed = time.monotonic() - started_at + _log.debug( + '[Combat] 经验结算增量 OCR: text={!r} results={} elapsed={:.2f}s', + text, + completed_results, + elapsed, + ) + if ( + len(completed_results) >= 3 + and completed_results[-1] == completed_results[-2] == completed_results[-3] + and elapsed >= EXP_SETTLEMENT_STABLE_SECONDS + ): + if signature.after_match_delay > 0: + time.sleep(signature.after_match_delay) + _log.info( + '[Combat] 经验结算页识别成功: results={}', + completed_results[-3:], + ) + return + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(interval, remaining)) + + _log.error( + '[Combat] 未能识别到经验结算页: results={} elapsed>={}s', + completed_results, + EXP_SETTLEMENT_TIMEOUT_SECONDS, + ) + raise TimeoutError('未能识别到经验结算页') + + def _click_result_until_closed( + self, + phase: CombatPhase, + *, + attempts: int = 6, + interval: float = 0.3, + polls: int = 4, + pass_through: tuple[CombatPhase, ...] = (), + ) -> None: + """点击战果类页面继续,并验证已到达**已知后继状态**。 + + 验证判据是到达验证而非"原页面签名消失"——点击 RESULT 战果页后, + 游戏先进入**经验结算子页** (逐舰船经验, 对应 CombatPhase.EXP_SETTLEMENT): + 若以"RESULT 消失"为成功判据, 复检会在经验页误判成功提前返回, + 引擎随后等待 PROCEED/GET_SHIP 等状态全部落空 + (实机 2026-08-15: 状态识别超时 → 恢复失败 → 强制重启)。 + + **快速点击 + 复检轮询**: 点击后每 *interval* 秒截图识别一次 + (最多 *polls* 次), 在 ``[phase] + pass_through + 后继状态`` 集合上判定: + - 命中后继 → 成功返回; + - 命中 *pass_through* 中的页面 (如快速穿行模式的经验页) → + 视为未到达, 继续点击跳过; + - 命中 *phase* → 点击被动画吞掉, 立即重试点击 (重试即动画等待); + - 识别不到 (过渡帧/页面渐变中) → **只等待不点击**: 对切换中的 + 页面盲目连点会穿透中间页, 落点若是 PROCEED 对话框还可能误触 + 按钮。轮询窗口耗尽仍认不出则交由外层状态机确认, 不再盲目点击。 + + Parameters + ---------- + phase: + 待关闭页面的状态签名 (RESULT / GET_SHIP)。 + attempts: + 最大点击次数。 + interval: + 复检轮询间隔 (秒)。 + polls: + 每次点击后的复检轮询上限 (过渡帧最长等待 attempts x interval x polls)。 + pass_through: + 识别到即**继续点击跳过**的过渡页 (快速穿行模式的 EXP_SETTLEMENT)。 + """ + successors = self._result_successors(phase) + candidates = [phase, *pass_through, *successors] + + if ( + phase is CombatPhase.RESULT + and isinstance(self._recognizer, CombatRecognizer) + and CombatPhase.EXP_SETTLEMENT in candidates + ): + click_result(self._device) + self._wait_for_exp_settlement() + if CombatPhase.EXP_SETTLEMENT in pass_through: + self._click_result_until_closed( + CombatPhase.EXP_SETTLEMENT, + attempts=attempts, + interval=interval, + polls=polls, + ) + return + + for attempt in range(1, attempts + 1): + click_result(self._device) + for _ in range(polls): + time.sleep(interval) + screen = self._device.screenshot() + if isinstance(self._recognizer, CombatRecognizer): + current = self._recognizer.identify_current_runtime(screen, candidates) + else: + current = self._recognizer.identify_current(screen, candidates) + if current is None: + # 过渡帧: 只等待, 不点击 (防穿透/误触)。GET_SHIP 模板 + # 可能因页面动画/字体渲染未命中, 用掉落页像素签名兜底探测, + # 防止快速穿行把掉落页直接点过去导致漏统计。 + if ( + CombatPhase.GET_SHIP in candidates + and self._is_get_ship_page(screen) + ): + _log.debug( + '[Combat] {} 过渡帧命中掉落页像素签名, 捕获掉落', + phase.name, + ) + self._capture_get_ship() + return + continue + if current in pass_through: + _log.debug( + '[Combat] {} 到达过渡页 {} (第 {} 次点击), 继续点击跳过', + phase.name, + current.name, + attempt, + ) + break # 过渡页: 继续点击 (与"被吞"同路径) + if current == phase: + _log.warning( + '[Combat] {} 页面未关闭 (第 {} 次点击), 延迟重试', phase.name, attempt + ) + break # 确认被吞 → 重试点击 + if current == CombatPhase.GET_SHIP: + # 点击穿透到掉落页: 立即幂等捕获掉落, 不依赖主循环重新派发 + self._capture_get_ship() + _log.debug('[Combat] {} 已推进到 {}', phase.name, current.name) + return + else: + # 整个轮询窗口都无法识别 → 交外层状态机继续确认, 避免跳过掉落页 + _log.debug( + '[Combat] {} 点击后持续无法识别 (第 {} 次), 等待外层状态机确认', + phase.name, + attempt, + ) + return + _log.error('[Combat] {} 页面点击 {} 次仍未推进, 继续执行', phase.name, attempts) + + def _result_successors(self, phase: CombatPhase) -> list[CombatPhase]: + """返回战果类页面点击后的**合法落点集合** (到达验证候选)。 + + 比 state.py 转移图宽: 转移图按真实流转建模, 此集合额外包含穿透 + 场景 — 快速点击下连点两击可能跳过中间页 (如 RESULT 点击穿透到 + GET_SHIP), 复检把它们都认出来即可提前停止点击, 交引擎主循环按 + 当前页继续。 + + 慢速 (collect_result_info=True): RESULT 之后含经验结算页 (作为 + 到达点逐页推进); 快速: 经验页是 pass_through 过渡页, 不在到达集。 + + RESULT 之后: PROCEED / 终态页 / GET_SHIP / 旗舰大破 (+经验页, 慢速); + EXP_SETTLEMENT 之后: 同上但去掉 EXP_SETTLEMENT 自身; + GET_SHIP 之后: 同上但去掉 GET_SHIP 自身。 + """ + successors = [CombatPhase.PROCEED, CombatPhase.FLAGSHIP_SEVERE_DAMAGE] + end_phase = self._plan.end_phase + if end_phase is not None: + successors.append(end_phase) + if phase == CombatPhase.RESULT: + successors.append(CombatPhase.GET_SHIP) + if self._plan.collect_result_info: + successors.append(CombatPhase.EXP_SETTLEMENT) + elif phase == CombatPhase.EXP_SETTLEMENT: + successors.append(CombatPhase.GET_SHIP) + return successors + + def _handle_exp_settlement(self) -> ConditionFlag: + """处理经验结算子页 — 点击继续推进到掉落/前进/终态页。""" + self._click_result_until_closed(CombatPhase.EXP_SETTLEMENT) return ConditionFlag.FIGHT_CONTINUE def _handle_get_ship(self) -> ConditionFlag: - """处理获取舰船。""" - ship_name = get_ship_drop(self._device, self._ocr) - if ship_name: - _log.info('[Combat] 获得舰船: {}', ship_name) + """处理获取舰船 — 幂等捕获掉落并关闭页面。""" + self._capture_get_ship() + self._click_result_until_closed(CombatPhase.GET_SHIP) + return ConditionFlag.FIGHT_CONTINUE + + def _capture_get_ship(self) -> str | None: + """幂等捕获掉落舰船: OCR 识别 + 记录历史。 + 同一节点已成功识别掉落 (如点击穿行时已记录) 则直接复用历史结果, + 避免主循环重新派发时重复 OCR。未识别结果不入历史, 让稳定页面重试。 + """ + existing = self._history.get_event(EventType.GET_SHIP, self._node) + if existing is not None: + return existing.result or None + ship_name = get_ship_drop(self._device, self._ocr) + if not ship_name: + return None + _log.info('[Combat] 获得舰船: {}', ship_name) self._history.add( CombatEvent( event_type=EventType.GET_SHIP, node=self._node, - result=ship_name or '', + result=ship_name, ) ) - click_result(self._device) - return ConditionFlag.FIGHT_CONTINUE + return ship_name + + def _is_get_ship_page(self, screen: np.ndarray) -> bool: + """兜底判定: GET_SHIP 模板未命中时用掉落页像素签名探测。 + + 掉落页有稳定的像素特征 (标题横幅/边框配色), 模板因动画或字体 + 渲染未命中时仍可借此避免穿透点击跳过掉落页。 + """ + return CombatRecognizer._match_pixel(screen, SHIP_DROP_PAGE_SIGNATURE) def _handle_proceed(self) -> ConditionFlag: """处理继续前进 / 回港决策。 diff --git a/autowsgr/combat/history.py b/autowsgr/combat/history.py index 1d2e752f..4812dd12 100644 --- a/autowsgr/combat/history.py +++ b/autowsgr/combat/history.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: from autowsgr.context.ship import Ship + from .plan import GradeCondition + _log = get_logger('combat') @@ -179,6 +181,17 @@ def _grade_index(self) -> int: return -1 +def grade_condition_met(condition: GradeCondition, result: CombatResult) -> bool: + """判定一次战斗结果是否满足 :class:`GradeCondition`。 + + 在该节点 (可能多次经过) 的所有结算中取**最后一次**, 战果等级 ``>=`` + 条件等级即达标 (比较复用 :class:`FightResult` 的等级序)。空结果 / + 节点未出现 / 未知等级 (等级序最末, 低于 D) 一律不达标。 + """ + matches = [fr for fr in result.fight_results if fr.node == condition.node] + return bool(matches) and matches[-1] >= condition.grade + + class CombatHistory: """一次完整战斗的事件历史记录。""" @@ -190,6 +203,22 @@ def add(self, event: CombatEvent) -> None: self.events.append(event) _log.debug('[History] 记录事件: {}', event) + def get_event( + self, + event_type: EventType, + node: str = '', + ) -> CombatEvent | None: + """按事件类型(可选限定节点)查找**最近一次**事件。 + + 用于幂等捕获:同一节点的掉落事件已记录过时不再重复 OCR。 + """ + for event in reversed(self.events): + if event.event_type == event_type and ( + not node or event.node == node + ): + return event + return None + def reset(self) -> None: """清空历史。""" count = len(self.events) @@ -282,6 +311,9 @@ class CombatResult: 出击舰队 (含等级、血量等信息, 战斗准备页面识别)。 ship_full: 是否已获取满 500 船。 + dock_full_destroyed: + 本轮船坞满后已自动解装成功 (flag 仍为 ``DOCK_FULL`` — 该轮未开打, + 不能翻成功标志污染触发器计数; 上层据本字段决定重试而非停止)。 """ flag: ConditionFlag = ConditionFlag.FIGHT_END @@ -294,6 +326,7 @@ class CombatResult: ship_acquired_count: int | None = None fleet: list[Ship] | None = None ship_full: bool = False + dock_full_destroyed: bool = False @property def fight_results(self) -> list[FightResult]: diff --git a/autowsgr/combat/node_tracker.py b/autowsgr/combat/node_tracker.py index 739d5749..1bcd7258 100644 --- a/autowsgr/combat/node_tracker.py +++ b/autowsgr/combat/node_tracker.py @@ -479,7 +479,7 @@ def update_node(self) -> str: self._last_ship_position = self._ship_position sx, sy = self._ship_position - # 速度方向(上一帧 -> 当前帧);首帧或零位移时退化为欧氏距离模式 + # 速度方向(上一帧 -> 当前帧);首帧退化为欧氏距离模式 has_ray = False vx = 0.0 vy = 0.0 @@ -490,12 +490,11 @@ def update_node(self) -> str: if not has_ray: _log.debug( - '[NodeTracker] has_ray=False,保持当前节点: {},位置: ({:.3f}, {:.3f})', + '[NodeTracker] 首帧无速度方向,按欧氏距离判断节点: {},位置: ({:.3f}, {:.3f})', self._current_node, sx, sy, ) - return self._current_node current_data = self._map_data.get(self._current_node) diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 7cd07015..63ecbd9f 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -13,7 +13,7 @@ import re from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Literal from autowsgr.infra import NodeConfig, load_yaml from autowsgr.infra.logger import get_logger @@ -31,6 +31,9 @@ _log = get_logger('combat') +#: 合法战果等级 (低 → 高, 与 :class:`FightResult` 的比较序一致) +_GRADE_VALUES: tuple[str, ...] = ('D', 'C', 'B', 'A', 'S', 'SS') + # ═══════════════════════════════════════════════════════════════════════════════ # 节点决策 @@ -70,6 +73,12 @@ class NodeDecision: 进入战斗时是否 SL(用于卡点)。 formation_when_spot_enemy_fails: 索敌失败时使用的替代阵型。 + grade: + 本节点要求的最低战果等级 (``D``/``C``/``B``/``A``/``S``/``SS``), + 空 = 无要求。写入 yaml ``node_args`` 的节点条目 (如 + ``node_args: {F: {grade: S}}``); 放 ``node_defaults`` 则所有节点 + 都要求。配置后计划自动启用慢速结算采集, 触发器按条件计数 + (见 :attr:`CombatPlan.conditions`)。 """ formation: Formation = Formation.double_column @@ -84,6 +93,18 @@ class NodeDecision: SL_when_detour_fails: bool = True SL_when_enter_fight: bool = False formation_when_spot_enemy_fails: Formation | None = None + grade: str = '' + + def __post_init__(self) -> None: + """校验并归一化 ``grade`` (空 = 无要求)。""" + if not self.grade: + return + grade = self.grade.upper() + if grade not in _GRADE_VALUES: + raise ValueError( + f'node grade: {self.grade!r} 不合法, 应为 {"/".join(_GRADE_VALUES)} 或留空', + ) + self.grade = grade @classmethod def from_node_config(cls, config: NodeConfig) -> NodeDecision: @@ -117,6 +138,7 @@ def from_node_config(cls, config: NodeConfig) -> NodeDecision: SL_when_detour_fails=config.SL_when_detour_fails, SL_when_enter_fight=config.SL_when_enter_fight, formation_when_spot_enemy_fails=formation_when_fail, + grade=config.grade, ) @classmethod @@ -194,6 +216,37 @@ class CombatMode: MODE_CATEGORIES: dict[str, ModeCategory] = {mode: cat for mode, (cat, _ep) in _MODE_SPECS.items()} +# ═══════════════════════════════════════════════════════════════════════════════ +# 战果条件 +# ═══════════════════════════════════════════════════════════════════════════════ + + +@dataclass(frozen=True) +class GradeCondition: + """战果达成条件: 指定节点的战果等级达标 (``>=`` 条件等级)。 + + 由 :attr:`NodeDecision.grade` (yaml ``node_args`` 下的 ``grade`` 键) + 派生的值对象, 配置了条件的计划自动启用慢速结算采集 (见 + :attr:`CombatPlan.conditions`), 触发器按条件判定是否计数。 + """ + + node: str + grade: str + + def __post_init__(self) -> None: + node = self.node.upper() + if len(node) != 1 or not 'A' <= node <= 'Z': + raise ValueError(f'节点名: {self.node!r} 不合法, 应为 A-Z 的单字母') + grade = self.grade.upper() + if grade not in _GRADE_VALUES: + raise ValueError( + f'战果要求: {self.grade!r} 不合法, 应为 {"/".join(_GRADE_VALUES)}', + ) + # 归一化为大写 (frozen 用 object.__setattr__) + object.__setattr__(self, 'node', node) + object.__setattr__(self, 'grade', grade) + + # ═══════════════════════════════════════════════════════════════════════════════ # 作战计划 # ═══════════════════════════════════════════════════════════════════════════════ @@ -241,6 +294,8 @@ class CombatPlan: GUI 整理后的舰队预设列表。 repair_mode: 修理策略。 + repair_method: + 维修方式。``None`` 表示未指定,兼容读取全局 ``repair_manually``。 fight_condition: 战况选择。 selected_nodes: @@ -264,23 +319,67 @@ class CombatPlan: fleet: list[str] | None = None fleet_presets: tuple[FleetPreset, ...] | None = None repair_mode: RepairMode | list[RepairMode] = RepairMode.severe_damage + repair_method: Literal['quick', 'bath'] | None = None fight_condition: FightCondition = FightCondition.aim selected_nodes: list[str] = field(default_factory=list) nodes: dict[str, NodeDecision] = field(default_factory=dict) + node_overrides: dict[str, dict[str, Any]] = field(default_factory=dict, repr=False) + """节点明确配置的原始字段,用于默认值更新后重新继承。""" default_node: NodeDecision = field(default_factory=NodeDecision) event_name: str | None = None """活动名称(如 ``"20260212"``),用于定位活动地图节点数据。 在 YAML 中写为 ``event: "20260212"``。""" + _force_collect_result: bool = field(default=True, repr=False, compare=False) + """运行时强制慢速采集开关 (默认开启, 仅供兼容 setter 使用)。""" def __post_init__(self) -> None: """\u5c06单个 repair_mode 展开为 6 个位置的列表,保证属性始终为 ``list[RepairMode]``。""" if not isinstance(self.repair_mode, list): self.repair_mode = [self.repair_mode] * 6 + @property + def conditions(self) -> tuple[GradeCondition, ...]: + """战果达成条件列表 — 从各节点的 ``grade`` (:attr:`NodeDecision.grade`) 派生。 + + yaml 形态 (``node_args`` 下):: + + node_args: + F: + grade: S # F 点要求 S 胜 (>= S) + + 配置后: ① 保持完整战果采集 (:attr:`collect_result_info` 默认已开启); ② 触发器 + (:class:`~autowsgr.scheduler.triggers.NormalFightTrigger`) 按条件判定本次战斗 + 是否计入次数 (所有配置 grade 的节点全部达标)。 + 空元组 (默认) 仍完整采集战果, 每场成功即计数。 + """ + return tuple( + GradeCondition(node=node, grade=decision.grade) + for node, decision in self.nodes.items() + if decision.grade + ) + + @property + def collect_result_info(self) -> bool: + """是否在战果/经验结算页停留采集信息 (评级/MVP) — 默认开启慢速通过。 + + 后端默认完整采集战果,避免普通计划因没有 grade 条件而跳过经验结算页。 + 兼容 setter: 运行时仍可显式赋值 ``False`` 请求快速穿行;GUI 不再暴露该开关。 + """ + return bool(self.conditions) or self._force_collect_result + + @collect_result_info.setter + def collect_result_info(self, value: bool) -> None: + self._force_collect_result = bool(value) + @property def transitions(self) -> dict[CombatPhase, PhaseBranch]: - """获取当前模式对应的状态转移图。""" - return MODE_TRANSITIONS[self.mode] + """当前计划的状态转移图 (按模式 + ``collect_result_info`` 构建)。""" + category, ep = _MODE_SPECS[self.mode] + return build_transitions( + category, + ep, + collect_result_info=self.collect_result_info, + ) @property def end_phase(self) -> CombatPhase: @@ -335,6 +434,14 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: else: repair_mode = RepairMode(repair_mode_raw) + # 维修方式缺省时保留旧版全局 repair_manually 兼容语义。 + repair_method_raw = data.get('repair_method') + if repair_method_raw not in (None, 'quick', 'bath'): + raise ValueError( + f'repair_method 不合法: {repair_method_raw!r}, 可选值: quick/bath', + ) + repair_method: Literal['quick', 'bath'] | None = repair_method_raw + # 默认节点配置 node_defaults = data.get('node_defaults', {}) default_node = NodeDecision.from_dict(node_defaults) @@ -342,6 +449,10 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: # 各节点配置 nodes: dict[str, NodeDecision] = {} node_args_data = data.get('node_args', {}) + node_overrides = { + node_name: copy.deepcopy(node_data or {}) + for node_name, node_data in node_args_data.items() + } if node_args_data: for node_name, node_data in node_args_data.items(): # 合并默认配置和节点特有配置 @@ -367,9 +478,11 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: fleet=fleet, fleet_presets=fleet_presets, repair_mode=repair_mode, + repair_method=repair_method, fight_condition=fight_condition, selected_nodes=selected_nodes, nodes=nodes, + node_overrides=node_overrides, default_node=default_node, event_name=event_name, ) diff --git a/autowsgr/combat/recognition.py b/autowsgr/combat/recognition.py index 45504ee3..99fcfa0e 100644 --- a/autowsgr/combat/recognition.py +++ b/autowsgr/combat/recognition.py @@ -236,33 +236,6 @@ def recognize_enemy_formation( _SHIP_TYPE_CROP = (0.79, 0.29, 0.95, 0.1, 25) """舰种 OCR 裁切参数 (bl_x, bl_y, tr_x, tr_y, angle)。""" -# 画面中显示的舰种全称 -> 标准中文短名 -_SHIP_TYPE_DISPLAY_MAP: dict[str, str] = { - '航空母舰': '航母', - '轻型航母': '轻母', - '装甲航母': '装母', - '战列舰': '战列', - '航空战列舰': '航战', - '战列巡洋舰': '战巡', - '重巡洋舰': '重巡', - '航空巡洋舰': '航巡', - '雷击巡洋舰': '雷巡', - '轻巡洋舰': '轻巡', - '重炮舰': '重炮', - '驱逐舰': '驱逐', - '导弹潜艇': '导潜', - '潜艇': '潜艇', - '炮击潜艇': '炮潜', - '补给舰': '补给', - '导弹驱逐舰': '导驱', - '防空驱逐舰': '防驱', - '导弹巡洋舰': '导巡', - '防空巡洋舰': '防巡', - '大型巡洋舰': '大巡', - '导弹战列舰': '导战', -} - - @dataclass(frozen=True, slots=True) class ShipDropResult: """舰船掉落识别结果。""" diff --git a/autowsgr/combat/recognizer.py b/autowsgr/combat/recognizer.py index 6445844e..66456857 100644 --- a/autowsgr/combat/recognizer.py +++ b/autowsgr/combat/recognizer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import time from dataclasses import dataclass from typing import TYPE_CHECKING @@ -14,6 +15,7 @@ PixelChecker, PixelRule, PixelSignature, + ROI, ) from .state import CombatPhase @@ -30,6 +32,16 @@ _log = get_logger('combat.recognition') +# 1280x720 经验结算页顶部「数字 + Exp」区域,运行时向外扩 1px。 +EXP_SETTLEMENT_ROI = ROI(272 / 1280, 4 / 720, 388 / 1280, 43 / 720).expand_pixels( + 1280, + 720, +) +_EXP_SETTLEMENT_ALLOWLIST = '0123456789EXPexp' +_EXP_SETTLEMENT_PATTERN = re.compile(r'\d+EXP') +EXP_SETTLEMENT_STABLE_SECONDS = 1.5 +EXP_SETTLEMENT_TIMEOUT_SECONDS = 10.0 + # ═══════════════════════════════════════════════════════════════════════════════ # 状态视觉签名 @@ -57,6 +69,11 @@ class PhaseSignature: 图像模板列表 (不归属 :class:`TemplateKey` 体系的自定义模板, 如活动标题图)。 ``template_key`` 与 ``pixel_signature`` 均为 ``None`` 时使用, ``find_any`` 命中任一即匹配。 + exclude_template_key: + 否决键: ``template_key`` 匹配成功后, 若此键对应的模板**也**命中则 + 整体判为不匹配。用于"共有元素 + 独有元素"区分结构相似页: + 如 MVP 徽章在战果页/经验页都出现 (稳定 0.94+), 而评级字母仅 + 战果页出现 — "MVP 有 + 评级无" 即经验结算页。 """ template_key: TemplateKey | None @@ -65,6 +82,7 @@ class PhaseSignature: after_match_delay: float = 0.0 pixel_signature: PixelSignature | None = None image_templates: list[ImageTemplate] | None = None + exclude_template_key: TemplateKey | None = None def _get_event_map_title_templates() -> list[ImageTemplate]: @@ -131,9 +149,20 @@ def _get_event_map_title_templates() -> list[ImageTemplate]: default_timeout=150.0, after_match_delay=1.75, ), + # 战果页判据用评级字母 (SS~D): 仅战果页出现 (经验页/出征准备页实测 + # 噪声 ≤0.48)。旧判据 result_540p ("点击继续") 两页都有且被舰船立绘 + # 遮挡致分数波动 (0.75~0.87), 无法区分还会误判。 CombatPhase.RESULT: PhaseSignature( - template_key=TemplateKey.RESULT, + template_key=TemplateKey.RESULT_GRADES, default_timeout=90.0, + confidence=0.85, + ), + # 经验页只由运行时固定 ROI OCR 判定;不再配置全屏模板。 + CombatPhase.EXP_SETTLEMENT: PhaseSignature( + template_key=None, + default_timeout=EXP_SETTLEMENT_TIMEOUT_SECONDS, + confidence=0.85, + after_match_delay=1.0, ), CombatPhase.GET_SHIP: PhaseSignature( template_key=TemplateKey.GET_SHIP_OR_ITEM, @@ -232,7 +261,14 @@ def _match_phase( ) -> bool: """检查截图是否匹配指定状态的视觉签名(模板、图像列表或像素)。""" if sig.template_key is not None: - return CombatRecognizer._match_template(screen, sig.template_key, sig.confidence) + if not CombatRecognizer._match_template(screen, sig.template_key, sig.confidence): + return False + # 否决键: 主键命中但排除键也命中 → 整体不匹配 + if sig.exclude_template_key is None: + return True + return not CombatRecognizer._match_template( + screen, sig.exclude_template_key, sig.confidence + ) if sig.image_templates is not None: return ( ImageChecker.find_any(screen, sig.image_templates, confidence=sig.confidence) @@ -242,6 +278,51 @@ def _match_phase( return CombatRecognizer._match_pixel(screen, sig.pixel_signature) return False + def _match_phase_runtime( + self, + screen: np.ndarray, + phase: CombatPhase, + sig: PhaseSignature, + ) -> bool: + """Match a phase using runtime-only recognizers when needed.""" + if phase is CombatPhase.EXP_SETTLEMENT: + return self._match_exp_settlement(screen) + return self._match_phase(screen, sig) + + def recognize_exp_settlement_text(self, screen: np.ndarray) -> str | None: + """Read allowed incremental characters from the fixed experience ROI.""" + ocr = getattr(self._ctx, 'ocr', None) + if ocr is None: + return None + + try: + results = ocr.recognize( + EXP_SETTLEMENT_ROI.crop(screen), + allowlist=_EXP_SETTLEMENT_ALLOWLIST, + ) + except RuntimeError as exc: + _log.debug('[Combat] 经验结算 OCR 暂时失败: {}', exc) + return None + + text = ''.join( + result.text + for result in sorted( + results, + key=lambda result: result.bbox[0] if result.bbox else 0, + ) + ) + normalized = re.sub(r'\s+', '', text).upper() + if not normalized or re.fullmatch(r'[0-9EXP]+', normalized) is None: + _log.debug('[Combat] 经验结算 ROI OCR 丢弃非法结果: {!r}', text) + return None + _log.debug('[Combat] 经验结算 ROI OCR: {!r} -> {!r}', text, normalized) + return normalized + + def _match_exp_settlement(self, screen: np.ndarray) -> bool: + """Recognize a complete ``digits + EXP`` result in the top ROI.""" + text = self.recognize_exp_settlement_text(screen) + return text is not None and _EXP_SETTLEMENT_PATTERN.fullmatch(text) is not None + @staticmethod def get_signature(phase: CombatPhase) -> PhaseSignature: """获取状态的视觉签名。""" @@ -303,13 +384,13 @@ def wait_for_phase( poll_action(screen) for phase, sig in phase_sigs: - if ( + if phase is not CombatPhase.EXP_SETTLEMENT and ( sig.template_key is None and sig.pixel_signature is None and sig.image_templates is None ): continue - if self._match_phase(screen, sig): + if self._match_phase_runtime(screen, phase, sig): if sig.after_match_delay > 0: time.sleep(sig.after_match_delay) _log.debug('[Combat] 匹配到状态: {}', phase.name) @@ -339,7 +420,7 @@ def identify_current( """ for phase in candidates: sig = CombatRecognizer.get_signature(phase) - if ( + if phase is CombatPhase.EXP_SETTLEMENT or ( sig.template_key is None and sig.pixel_signature is None and sig.image_templates is None @@ -349,6 +430,24 @@ def identify_current( return phase return None + def identify_current_runtime( + self, + screen: np.ndarray, + candidates: list[CombatPhase], + ) -> CombatPhase | None: + """Identify a phase with runtime OCR-aware checks.""" + for phase in candidates: + sig = self.get_signature(phase) + if phase is not CombatPhase.EXP_SETTLEMENT and ( + sig.template_key is None + and sig.pixel_signature is None + and sig.image_templates is None + ): + continue + if self._match_phase_runtime(screen, phase, sig): + return phase + return None + class CombatRecognitionTimeoutError(Exception): """战斗状态识别超时。""" diff --git a/autowsgr/combat/rules.py b/autowsgr/combat/rules.py index bee84c15..f2595c2d 100644 --- a/autowsgr/combat/rules.py +++ b/autowsgr/combat/rules.py @@ -40,9 +40,6 @@ # 允许在规则中出现的舰种标识符 _log = get_logger('combat.recognition') -_SHIP_TYPE_PATTERN = re.compile( - rf'\b({"|".join(re.escape(vessel_type.native.as_english()) for vessel_type in FLEET_VESSEL_TYPES)})\b', -) _RULE_FIELD_CODES = {vessel_type.native.as_english() for vessel_type in FLEET_VESSEL_TYPES} | { 'ALL' } @@ -304,9 +301,15 @@ def _parse_legacy_condition(condition_str: str) -> list[Condition]: 不支持 ``or`` — 用多条规则替代。 支持 ``+`` 运算符将多个舰种求和,如 ``CL + DD >= 1``。 + 兼容舰种代码大小写和组合运算符中间的空格,如 ``ap > = 1``。 """ conditions: list[Condition] = [] - parts = _CONDITION_SEPARATOR_RE.split(condition_str.strip()) + normalized_condition = re.sub( + r'([<>!])\s*=\s*', + r'\1=', + condition_str.strip().upper(), + ) + parts = _CONDITION_SEPARATOR_RE.split(normalized_condition) if not parts or any(not part.strip() for part in parts): raise ValueError(f"无法解析规则条件: '{condition_str}'") for part in parts: diff --git a/autowsgr/combat/state.py b/autowsgr/combat/state.py index cc63e78d..8d934be7 100644 --- a/autowsgr/combat/state.py +++ b/autowsgr/combat/state.py @@ -1,7 +1,8 @@ """战斗状态枚举与状态转移图。 一次完整的 MAP 类战斗流程:: PROCEED → FIGHT_CONDITION → SPOT_ENEMY_SUCCESS → FORMATION - → FIGHT_PERIOD → NIGHT_PROMPT → RESULT → GET_SHIP → PROCEED → ... + → FIGHT_PERIOD → NIGHT_PROMPT → RESULT → EXP_SETTLEMENT + → GET_SHIP → PROCEED → ... """ from __future__ import annotations @@ -55,6 +56,9 @@ class CombatPhase(Enum): RESULT = auto() """战果评价界面(S/A/B/C/D/SS)。""" + EXP_SETTLEMENT = auto() + """经验结算子页(战果页点击后,逐舰船显示经验增加/"升级剩余经验")。""" + # ── 掉落 ── GET_SHIP = auto() """获取舰船掉落。""" @@ -102,6 +106,8 @@ class ModeCategory(Enum): def build_transitions( category: ModeCategory, end_page: CombatPhase | None, + *, + collect_result_info: bool = True, ) -> dict[CombatPhase, PhaseBranch]: """根据模式大类和结束页面自动构建状态转移图。 @@ -111,18 +117,23 @@ def build_transitions( ``MAP`` 或 ``SINGLE``。 end_page: 战斗结束游戏回到的页面状态。``None`` 表示以 ``RESULT`` 作为终止态。 + collect_result_info: + 是否在战果页停留采集评级/MVP (慢速通过)。``True`` (默认) 时经验结算页 + 进入状态机 (:attr:`CombatPhase.EXP_SETTLEMENT`),处理器在 RESULT 页完成信息 + 采集后再逐页推进;显式传入 ``False`` 才启用快速穿行兼容路径。 Returns ------- dict[CombatPhase, PhaseBranch] """ if category == ModeCategory.MAP: - return _build_map_transitions(end_page) - return _build_single_transitions(end_page) + return _build_map_transitions(end_page, collect_result_info) + return _build_single_transitions(end_page, collect_result_info) def _build_map_transitions( end_page: CombatPhase | None, + collect_result_info: bool = True, ) -> dict[CombatPhase, PhaseBranch]: """MAP 类:多节点地图战斗的完整转移图。""" ep = end_page # 简写 @@ -193,9 +204,16 @@ def _build_map_transitions( 'no': [CombatPhase.RESULT], } - t[CombatPhase.RESULT] = list(after_result) + # 战果页点击后必进经验结算子页 (游戏固定流转), 掉落/继续前进等 + # 只能从经验页到达。默认慢速模式进入状态机逐页采集 grade/MVP; + # 仅显式传入 collect_result_info=False 时才将经验页作为过渡页穿行。 + if collect_result_info: + t[CombatPhase.RESULT] = [CombatPhase.EXP_SETTLEMENT] + else: + t[CombatPhase.RESULT] = list(after_result) + t[CombatPhase.EXP_SETTLEMENT] = list(after_result) - # GET_SHIP 后继 = RESULT 后继 去掉 GET_SHIP 自身 + # GET_SHIP 后继 = 经验结算后继 去掉 GET_SHIP 自身 t[CombatPhase.GET_SHIP] = [p for p in after_result if p != CombatPhase.GET_SHIP] if ep is not None: @@ -206,6 +224,7 @@ def _build_map_transitions( def _build_single_transitions( end_page: CombatPhase | None, + collect_result_info: bool = True, ) -> dict[CombatPhase, PhaseBranch]: """SINGLE 类:单点战斗的精简转移图。""" ep = end_page @@ -233,8 +252,17 @@ def _build_single_transitions( } if ep is not None: - t[CombatPhase.RESULT] = [ep] - # ep is None → RESULT 为终止态,无后继 + # 演习等: 默认慢速逐页 (战果→经验→结束), 显式 False 时快速穿行 + if collect_result_info: + t[CombatPhase.RESULT] = [CombatPhase.EXP_SETTLEMENT] + t[CombatPhase.EXP_SETTLEMENT] = [ep] + else: + t[CombatPhase.RESULT] = [ep] + t[CombatPhase.EXP_SETTLEMENT] = [ep] + else: + # ep is None (战役/决战) → RESULT 为终止态;引擎在 RESULT 即返回, + # 经验页由 _click_result_until_closed 的候选集合兜住, 无转移后继。 + t[CombatPhase.EXP_SETTLEMENT] = [] return t diff --git a/autowsgr/context/game_context.py b/autowsgr/context/game_context.py index 7058a7f3..9662df1f 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 autowsgr.ui.stack import UIStack from .bathroom import BathRoom from .build import BuildQueue @@ -93,8 +94,20 @@ class GameContext: """浴室修理槽位状态 (空位调度用)。""" ship_registry: dict[str, Ship] = field(default_factory=dict) """舰船注册表, 以名称为键。""" + ui_stack: UIStack = field(default_factory=UIStack) + """UI 导航栈 — 页面来路追踪。 + + 识别候选剪枝与 go_back 期望父页的来源, 由导航循环 + (:func:`autowsgr.ops.navigate._goto_page`) 维护。 + """ current_page: PageName | None = None - """当前游戏页面。""" + """当前游戏页面。 + + .. deprecated:: + 改读 :attr:`ui_stack.current`。本字段由导航循环尽力同步 + (页面名不在 :class:`~autowsgr.types.PageName` 枚举内时为 ``None``), + 仅为 server 状态上报等旧读者保留。 + """ # ── 每日计数器 ── @@ -135,6 +148,10 @@ def is_ship_available(self, name: str) -> bool: def update_ship_damage(self, name: str, state: ShipDamageState) -> None: """更新舰船的破损状态。""" self.get_ship(name).damage_state = state + for fleet in self.fleets: + for ship in fleet.ships: + if ship.name == name: + ship.damage_state = state # ── 战斗上下文同步 ── @@ -176,7 +193,7 @@ def sync_before_combat( if s.name: registered = self.get_ship(s.name) registered.level = s.level or registered.level - registered.damage_state = s.damage_state + self.update_ship_damage(s.name, s.damage_state) _log.info( '[Context] 舰队 {} 出击编成: {}', fleet_id, @@ -211,9 +228,10 @@ def sync_after_combat( if i < len(result.ship_stats): state = result.ship_stats[i] if state != ShipDamageState.NO_SHIP: - ship.damage_state = state if ship.name: - self.get_ship(ship.name).damage_state = state + self.update_ship_damage(ship.name, state) + else: + ship.damage_state = state # 统计本次掉落舰船数 fight_results = result.fight_results @@ -251,8 +269,12 @@ def sync_daily_drop_counts(self) -> None: map_page = MapPage(self) map_page.ensure_panel(MapPanel.SORTIE) time.sleep(0.25) + # 战利品 OCR 与 YAML 联动: 仅在开启 stop_max_loot (战利品检查) 时识别, + # 无战利品活动时跳过该区域避免无效 OCR 报警 + da = self.config.daily_automation + read_loot = bool(da and da.stop_max_loot) # OCR 引擎不可用时 get_loot_and_ship_count 抛 RuntimeError — 不吞, 上抛 - counts = map_page.get_loot_and_ship_count() + counts = map_page.get_loot_and_ship_count(read_loot=read_loot) if counts.ship is not None: self.dropped_ship_count = counts.ship if counts.loot is not None: diff --git a/autowsgr/data/images/bath/bath_540p.png b/autowsgr/data/images/bath/bath_540p.png new file mode 100644 index 00000000..a23596a3 Binary files /dev/null and b/autowsgr/data/images/bath/bath_540p.png differ diff --git a/autowsgr/data/images/bath/choose_repair_720p.png b/autowsgr/data/images/bath/choose_repair_720p.png new file mode 100644 index 00000000..86761bf7 Binary files /dev/null and b/autowsgr/data/images/bath/choose_repair_720p.png differ diff --git a/autowsgr/data/images/combat/result_540p.png b/autowsgr/data/images/combat/result_540p.png deleted file mode 100644 index 2610092d..00000000 Binary files a/autowsgr/data/images/combat/result_540p.png and /dev/null differ diff --git a/autowsgr/data/images/common/back_1_540p.png b/autowsgr/data/images/common/back_1_540p.png new file mode 100644 index 00000000..2f681033 Binary files /dev/null and b/autowsgr/data/images/common/back_1_540p.png differ diff --git a/autowsgr/data/images/common/back_2_540p.png b/autowsgr/data/images/common/back_2_540p.png new file mode 100644 index 00000000..d285a917 Binary files /dev/null and b/autowsgr/data/images/common/back_2_540p.png differ diff --git a/autowsgr/data/images/common/back_3_540p.png b/autowsgr/data/images/common/back_3_540p.png new file mode 100644 index 00000000..6125d61d Binary files /dev/null and b/autowsgr/data/images/common/back_3_540p.png differ diff --git a/autowsgr/data/images/common/back_4_540p.png b/autowsgr/data/images/common/back_4_540p.png new file mode 100644 index 00000000..830d7ed4 Binary files /dev/null and b/autowsgr/data/images/common/back_4_540p.png differ diff --git a/autowsgr/data/images/common/back_5_540p.png b/autowsgr/data/images/common/back_5_540p.png new file mode 100644 index 00000000..30ee7061 Binary files /dev/null and b/autowsgr/data/images/common/back_5_540p.png differ diff --git a/autowsgr/data/images/common/back_6_540p.png b/autowsgr/data/images/common/back_6_540p.png new file mode 100644 index 00000000..1e18d2ff Binary files /dev/null and b/autowsgr/data/images/common/back_6_540p.png differ diff --git a/autowsgr/data/images/common/back_7_540p.png b/autowsgr/data/images/common/back_7_540p.png new file mode 100644 index 00000000..12dd0d1b Binary files /dev/null and b/autowsgr/data/images/common/back_7_540p.png differ diff --git a/autowsgr/data/images/common/back_8_540p.png b/autowsgr/data/images/common/back_8_540p.png new file mode 100644 index 00000000..c1731c93 Binary files /dev/null and b/autowsgr/data/images/common/back_8_540p.png differ diff --git a/autowsgr/data/images/decisive/advance_choice_720p.png b/autowsgr/data/images/decisive/advance_choice_720p.png new file mode 100644 index 00000000..0c5d2409 Binary files /dev/null and b/autowsgr/data/images/decisive/advance_choice_720p.png differ diff --git a/autowsgr/data/images/decisive/confirm_exit_720p.png b/autowsgr/data/images/decisive/confirm_exit_720p.png new file mode 100644 index 00000000..ee8f1dc6 Binary files /dev/null and b/autowsgr/data/images/decisive/confirm_exit_720p.png differ diff --git a/autowsgr/data/images/decisive/decisive_map_540p.png b/autowsgr/data/images/decisive/decisive_map_540p.png new file mode 100644 index 00000000..64188faf Binary files /dev/null and b/autowsgr/data/images/decisive/decisive_map_540p.png differ diff --git a/autowsgr/data/images/decisive/fleet_acq_720p.png b/autowsgr/data/images/decisive/fleet_acq_720p.png new file mode 100644 index 00000000..de7c9b97 Binary files /dev/null and b/autowsgr/data/images/decisive/fleet_acq_720p.png differ diff --git a/autowsgr/data/images/decisive/fleet_name.png b/autowsgr/data/images/decisive/fleet_name.png new file mode 100644 index 00000000..365c837f Binary files /dev/null and b/autowsgr/data/images/decisive/fleet_name.png differ diff --git a/autowsgr/data/images/decisive/reset_button.png b/autowsgr/data/images/decisive/reset_button.png new file mode 100644 index 00000000..f9b3fab1 Binary files /dev/null and b/autowsgr/data/images/decisive/reset_button.png differ diff --git a/autowsgr/data/images/main_page/booking_540p.png b/autowsgr/data/images/main_page/booking_540p.png new file mode 100644 index 00000000..336e9a6c Binary files /dev/null and b/autowsgr/data/images/main_page/booking_540p.png differ diff --git a/autowsgr/data/images/main_page/main_page_540p.png b/autowsgr/data/images/main_page/main_page_540p.png new file mode 100644 index 00000000..eee4a78a Binary files /dev/null and b/autowsgr/data/images/main_page/main_page_540p.png differ diff --git a/autowsgr/data/images/main_page/news_720p.png b/autowsgr/data/images/main_page/news_720p.png new file mode 100644 index 00000000..ea8b32b5 Binary files /dev/null and b/autowsgr/data/images/main_page/news_720p.png differ diff --git a/autowsgr/data/images/main_page/sign_720p.png b/autowsgr/data/images/main_page/sign_720p.png new file mode 100644 index 00000000..f3cd50bd Binary files /dev/null and b/autowsgr/data/images/main_page/sign_720p.png differ diff --git a/autowsgr/data/images/main_page/user_info_720p.png b/autowsgr/data/images/main_page/user_info_720p.png new file mode 100644 index 00000000..921de7bd Binary files /dev/null and b/autowsgr/data/images/main_page/user_info_720p.png differ diff --git a/autowsgr/data/images/page/backyard_540p.png b/autowsgr/data/images/page/backyard_540p.png new file mode 100644 index 00000000..7ec20894 Binary files /dev/null and b/autowsgr/data/images/page/backyard_540p.png differ diff --git a/autowsgr/data/images/page/canteen_540p.png b/autowsgr/data/images/page/canteen_540p.png new file mode 100644 index 00000000..4ba49027 Binary files /dev/null and b/autowsgr/data/images/page/canteen_540p.png differ diff --git a/autowsgr/data/images/page/fight_prepare_540p.png b/autowsgr/data/images/page/fight_prepare_540p.png new file mode 100644 index 00000000..be52e91f Binary files /dev/null and b/autowsgr/data/images/page/fight_prepare_540p.png differ diff --git a/autowsgr/data/images/page/sidebar_540p.png b/autowsgr/data/images/page/sidebar_540p.png new file mode 100644 index 00000000..eabdc7bd Binary files /dev/null and b/autowsgr/data/images/page/sidebar_540p.png differ diff --git a/autowsgr/data/map/decisive_battle/enemy_spec.yaml b/autowsgr/data/map/decisive_battle/enemy_spec.yaml deleted file mode 100644 index 4801bc68..00000000 --- a/autowsgr/data/map/decisive_battle/enemy_spec.yaml +++ /dev/null @@ -1,135 +0,0 @@ -key_points: - 1: - - "" - 2: - - - 3: - - "" - 4: - - "" - - "CFH" - - "BFH" - - "DHJ" - 5: - - "" - - "DFH" - - "DGJ" - - "CGJ" - 6: - - "" - - "BGJ" - - "CHJ" - - "DGJ" -map_end: - - "" - - " FHH" - - " FHH" - - " HHJ" - - " HHJ" - - " HJJ" - - " JJJ" -enemy: - - "" - - "" - - "" - - "" - - - - "" - - - A: ['', 'CL', 'CL', 'DD'] - B: ['', 'CA', 'CA', 'DD', 'DD'] - C: ['', "CLT", "CLT", "CL", "CL", "SS"] - D: ['', 'BC', 'CVL', 'CVL', 'CL', 'DD'] - E: ['', 'BB', 'CA', 'CL', 'CVL', 'CL', 'DD'] - F: ['', 'CV', 'BC', 'CL', 'DD', 'DD', 'AADG'] - G: ['', 'BC', 'BG', 'CA', 'CA', 'CL', 'DD'] - H: ['', 'CV', 'CV', 'CVL', 'CL', 'BBV', 'BBV'] - - - A: ['', 'BC', 'CV', 'CV', 'CL', 'DD', 'DD'] - B: ['', 'BB', 'BC', 'BC', 'CA', 'DD', 'SS'] - C: ['', 'CV', 'CV', 'BB', 'CLT', 'CL', 'CL'] - D: ['', 'BB', 'CV', 'BG', 'CA', 'DD', 'AADG'] - E: ['', 'BC', 'BC', 'CV', 'CL', 'CL', 'ASDG'] - F: ['', 'BB', 'BB', 'BB', 'CA', 'DD', 'SS'] - G: ['', 'BB', 'BB', 'CV', 'CVL', 'CL', 'CL'] - H: ['', 'AF', 'BC', 'BB', 'BB', 'BB', 'CA'] - - - A: ['', 'BB', 'BC', 'BB', 'CA', 'SS', 'SS'] - B: ['', 'CV', 'BC', 'BC', 'CVL', 'DD', 'ASDG'] - C: ['', 'CV', 'CV', 'BB', 'BC', 'DD', 'DD'] - D: ['', 'BB', 'CV', 'BC', 'CA', 'CL', 'BG'] - E: ['', 'CV', 'BC', 'BC', 'CA', 'SS', 'CL'] - F: ['', 'BB', 'BB', 'BC', 'BC', 'CL', 'CL'] - G: ['', 'BB', 'BB', 'CA', 'CA', 'CA'] - H: ['', 'BB', 'CV', 'BB', 'BC', 'CL', 'SS'] - I: ['', 'BB', 'BBG', 'CV', 'CA', 'CA', 'CL'] - J: ['', 'BB', 'CA', 'BBV', 'BBV', 'SS', 'BC'] - - - - - "" - - - A: ['', 'DD', 'SS', '', '', '', ''] - B: ['', 'CA', 'CL', 'AADG', '', '', ''] - C: ['', 'BB', 'CA', 'DD', '', '', ''] - D: ['', 'BC', 'CA', 'SS', 'SS', '', ''] - E: ['', 'BB', 'CVL', 'CVL', 'DD', 'DD', ''] - F: ['', 'CV', 'CV', 'CL', 'CL', 'DD', 'DD'] - G: ['', 'BC', 'CVL', 'CA', 'CL', 'DD', 'AADG'] - H: ['', 'AV', 'CV', 'CV', 'BB', 'DD', 'BBV'] - - - A: ['', 'BB', 'CV', 'CV', 'CVL', 'CL', 'CL'] - B: ['', 'CV', 'BB', 'BB', 'CA', 'CL', 'CL'] - C: ['', 'BC', 'BB', 'BB', 'CA', 'CL', 'SS'] - D: ['', 'BB', 'BB', 'BC', 'SS', 'SS', 'SS'] - E: ['', 'BB', 'BB', 'BB', 'CL', 'CL', 'SS'] - F: ['', 'BC', 'BC', 'BB', 'CA', 'CA', 'AADG'] - G: ['', 'BC', 'CV', 'BB', 'BG', 'CL', 'CL'] - H: ['', 'BB', 'BB', 'BB', 'ASDG', 'CL', 'CL'] - I: ['', 'CV', 'BB', 'BB', 'CL', 'AADG', 'AADG'] - J: ['', 'AV', 'AF', 'BC', 'BB', 'CLT', 'CLT'] - - - A: ['', 'CV', 'CV', 'BC', 'CA', 'CL', 'SS'] - B: ['', 'BB', 'BB', 'BC', 'CL', 'CL', 'ASDG'] - C: ['', 'BB', 'BBG', 'BC', 'BC', 'CL', 'CL'] - D: ['', 'BB', 'BC', 'BC', 'CA', 'CA', 'SS'] - E: ['', 'BB', 'BB', 'CVL', 'CVL', 'CL', 'AADG'] - F: ['', 'CV', 'CVL', 'BB', 'BC', 'CA', 'CL'] - G: ['', 'CV', 'CV', 'BB', 'BB', 'CA', 'CA'] - H: ['', 'CV', 'BC', 'BB', 'BC', 'CA', 'CL'] - I: ['', 'BB', 'BB', 'BB', 'BB', 'CL', 'CL'] - J: ['', 'BB', 'BC', 'BC', 'CA', 'CA', 'DD'] - - - - - "" - - A : ['', 'CL', 'CL'] - B : ['', 'CLT', 'SS', 'SS'] - C : ['', 'CA', 'CA', 'CA', 'CA'] - D : ['', 'BC', 'CA', 'CA', 'CL', 'DD', 'AADG'] - E : ['', 'CV', 'BC', 'CA', 'CA', 'DD', 'DD'] - F : ['', 'CVL', 'BC', 'BC', 'CL', 'DD', 'SS'] - G : ['', 'BB', 'BB', 'CA', 'CL', 'CL', 'AADG'] - H : ['', 'BB', 'BB', 'BC', 'CVL', 'CL', 'DD'] - I : ['', 'BC', 'BB', 'CV', 'CVL', 'CL', 'DD'] - J : ['', 'AF', 'BB', 'DD', 'BB', 'BC', 'AV'] - - - A: ["", BB, BB, BC, CVL, CL, CL] - B: ["", CV, BC, BB, CA, CA, CL] - C: ["", BC, BB, BB, BC, CA, SS] - D: ["", CV, CV, BB, CA, CL, SS] - E: ["", BC, BC, CV, CV, CL, CL] - F: ["", BB, BB, BB, CA, CA, AADG] - G: ["", CV, BBG, CV, CA, CA, CA] - H: ["", BB, BB, CV, BC, CA, ASDG] - I: ["", CV, CVL, BB, BB, CA, SS] - J: ["", AV, CV, CV, AF, CVL, DD] - - - A: ["", CV, BC, BB, BB, CL, AADG] - B: ["", BB, BC, BB, BB, CA, SS] - C: ["", BB, BC, BB, BG, BG, CL] - D: ["", BB, BC, BB, CA, ASDG, CL] - E: ["", CV, BB, CV, BB, CL, CL] - F: ["", BB, BC, BB, BB, CA, CA] - G: ["", BB, CV, BC, BB, SS, SS] - H: ["", CV, BB, BB, BC, CL, AADG] - I: ["", CV, CV, BB, BB, CL, BBG] - J: ["", BB, BB, CA, BBV, BBV, BC] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-1.yaml new file mode 100644 index 00000000..087bf579 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-1.yaml @@ -0,0 +1,27 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-1-1 +chapter: 1 +stage: 1 +key_points: [B, D, F] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E2]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F1]} + F1: {label: F, column: 6, index: 0, next: []} +enemy: + A: [CL] + B: [DD, DD] + C: [CL, DD, DD] + D: [DD, CL, SS] + E: [CA, CVL, DD, DD] + F: [CV, BC, SS, CLT, BBV, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-2.yaml new file mode 100644 index 00000000..265ffa34 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-2.yaml @@ -0,0 +1,36 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-1-2 +chapter: 1 +stage: 2 +key_points: [B, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2, B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + B3: {label: B, column: 2, index: 2, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + D2: {label: D, column: 4, index: 1, next: [E2, E3]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F2]} + E3: {label: E, column: 5, index: 2, next: [F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1]} + H1: {label: H, column: 8, index: 0, next: []} +enemy: + A: [CL, DD, CL, CL, DD] + B: [CA, CA, CL, DD, DD, DD] + C: [BC, CL, DD, DD, DD, DD] + D: [CA, CVL, CL, CL, CL, DD] + E: [CVL, CA, CA, CL, DD, DD] + F: [CA, CL, CL, CL, DD, SS] + G: [BC, CVL, CL, CL, DD, DD] + H: [CVL, BB, BB, BBV, CLT, BC] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-3.yaml new file mode 100644 index 00000000..e0e23550 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-1-3.yaml @@ -0,0 +1,35 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-1-3 +chapter: 1 +stage: 3 +key_points: [D, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + D2: {label: D, column: 4, index: 1, next: [E2, E3]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F1, F2]} + E3: {label: E, column: 5, index: 2, next: [F2]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [CA, CA, CA, CL, CL, DD] + B: [BC, CA, CL, DD, DD, SS] + C: [BB, CA, DD, CL, CL, CL] + D: [CV, CVL, CA, CL, DD, DD] + E: [CV, CA, CA, CL, DD, AADG] + F: [CV, BB, CA, CL, DD, DD] + G: [BB, BC, CVL, CL, CL, DD] + H: [BC, BBV, CLT, BBV, BBV, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-1.yaml new file mode 100644 index 00000000..734b47fc --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-1.yaml @@ -0,0 +1,27 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-2-1 +chapter: 2 +stage: 1 +key_points: [C, F] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2, D3]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1]} + D3: {label: D, column: 4, index: 2, next: [E1]} + E1: {label: E, column: 5, index: 0, next: [F1]} + F1: {label: F, column: 6, index: 0, next: []} +enemy: + A: [DD, DD] + B: [DD, DD, AADG] + C: [CL, DD, CL, DD] + D: [CA, CL, CL, DD] + E: [CA, CL, AADG, SS] + F: [CV, CV, CV, BC, BBV, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-2.yaml new file mode 100644 index 00000000..f8d37133 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-2.yaml @@ -0,0 +1,36 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-2-2 +chapter: 2 +stage: 2 +key_points: [B, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + A2: {label: A, column: 1, index: 1, next: [B2, B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C2]} + B3: {label: B, column: 2, index: 2, next: [C3]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1, D2]} + C3: {label: C, column: 3, index: 2, next: [D2]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [BC, BC, CVL, CL, DD, DD] + B: [BB, BC, CA, CL, DD, DD] + C: [BC, CA, CL, DD, SS, SS] + D: [BB, CVL, CA, CL, DD, ASDG] + E: [BB, BB, CVL, CVL, DD, DD] + F: [BB, CV, CVL, CA, CL, DD] + G: [BB, CV, CA, CL, DD, SS] + H: [AF, BC, BB, BB, SS, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-3.yaml new file mode 100644 index 00000000..6a9171cd --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-2-3.yaml @@ -0,0 +1,37 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-2-3 +chapter: 2 +stage: 3 +key_points: [D, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + B1: {label: B, column: 2, index: 0, next: [C1, C2]} + B2: {label: B, column: 2, index: 1, next: [C2, C3]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D2]} + C3: {label: C, column: 3, index: 2, next: [D3]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1, E2]} + D3: {label: D, column: 4, index: 2, next: [E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + F3: {label: F, column: 6, index: 2, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [BB, BC, CVL, CL, CL, DD] + B: [CV, CV, CA, CL, CL, DD] + C: [BB, CV, CVL, CA, CL, CL] + D: [BB, BG, BC, CA, CA, DD] + E: [CV, BB, BC, DD, DD, SS] + F: [BB, BB, CV, CL, CL, SS] + G: [BB, BC, BB, CVL, CL, SS] + H: [BB, CVL, BC, CL, SS, CA] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-1.yaml new file mode 100644 index 00000000..6ee71850 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-1.yaml @@ -0,0 +1,35 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-3-1 +chapter: 3 +stage: 1 +key_points: [C, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2, B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1, C2]} + B3: {label: B, column: 2, index: 2, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D2]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1]} + E1: {label: E, column: 5, index: 0, next: [F1, F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + F3: {label: F, column: 6, index: 2, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [CL, DD] + B: [DD, DD, SS] + C: [CVL, CA, CL, DD] + D: [CA, CVL, CA, CL, DD] + E: [CA, CA, CA, CA, DD, DD] + F: [CA, CA, CLT, CL, DD, SS] + G: [CV, CA, CA, CL, CL, DD] + H: [CV, CV, BC, CVL, CL, BB] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-2.yaml new file mode 100644 index 00000000..e47f8d51 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-2.yaml @@ -0,0 +1,34 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-3-2 +chapter: 3 +stage: 2 +key_points: [B, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2, D3]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1, E2]} + D3: {label: D, column: 4, index: 2, next: [E2]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F1]} + F1: {label: F, column: 6, index: 0, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [BB, BB, BC, DD, DD, CL] + B: [CV, BC, BC, CVL, DD, DD] + C: [BB, BB, BG, CA, CL, DD] + D: [BC, CV, CV, DD, DD, SS] + E: [BC, CV, CV, CVL, CL, CL] + F: [BB, BC, CV, CL, AADG, SS] + G: [BB, BB, BC, BC, CLT, DD] + H: [BB, BC, BC, CLT, SS, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-3.yaml new file mode 100644 index 00000000..15cd4a7e --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-3-3.yaml @@ -0,0 +1,44 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-3-3 +chapter: 3 +stage: 3 +key_points: [D, H, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2, A3]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B2]} + A3: {label: A, column: 1, index: 2, next: [B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1, C2]} + B3: {label: B, column: 2, index: 2, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D2]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E1]} + E1: {label: E, column: 5, index: 0, next: [F1, F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1]} + H1: {label: H, column: 8, index: 0, next: [I1, I2]} + I1: {label: I, column: 9, index: 0, next: [J1, J2]} + I2: {label: I, column: 9, index: 1, next: [J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [BC, CV, BC, CVL, CL, DD] + B: [BC, BC, BB, CA, CL, CL] + C: [BB, BB, BC, CL, CL, ASDG] + D: [CV, BB, BG, CA, CA, CA] + E: [BB, BC, BC, CV, CL, DD] + F: [BB, BB, BC, CA, CL, SS] + G: [BB, BB, CV, CVL, SS, SS] + H: [CV, CV, BB, CL, CL, AADG] + I: [BC, BB, BC, CV, CVL, DD] + J: [BC, BBV, BBV, BC, CA, BC] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-1.yaml new file mode 100644 index 00000000..cb807389 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-1.yaml @@ -0,0 +1,34 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-4-1 +chapter: 4 +stage: 1 +key_points: [C, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1]} + H1: {label: H, column: 8, index: 0, next: []} +enemy: + A: [CL, CL, DD] + B: [CA, CA, DD, DD] + C: [CLT, CLT, CL, CL, SS] + D: [BC, CVL, CVL, CL, DD] + E: [BB, CA, CL, CVL, CL, DD] + F: [CV, BC, CL, DD, DD, AADG] + G: [BB, BG, CA, CA, CL, DD] + H: [CV, CV, CVL, CL, BBV, BBV] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-2.yaml new file mode 100644 index 00000000..18467d54 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-2.yaml @@ -0,0 +1,35 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-4-2 +chapter: 4 +stage: 2 +key_points: [B, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + A2: {label: A, column: 1, index: 1, next: [B2, B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1, C2]} + B3: {label: B, column: 2, index: 2, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F2]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} +enemy: + A: [BC, CV, CV, CL, DD, DD] + B: [BB, BC, BC, CA, DD, SS] + C: [CV, CV, BB, CLT, CL, CL] + D: [BB, CV, BG, CA, DD, AADG] + E: [BC, BC, CV, CL, CL, ASDG] + F: [BB, BB, BB, CA, DD, SS] + G: [BB, BB, CV, CVL, CL, CL] + H: [AF, BC, BB, BB, BB, CA] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-3.yaml new file mode 100644 index 00000000..499999a6 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-4-3.yaml @@ -0,0 +1,43 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-4-3 +chapter: 4 +stage: 3 +key_points: [D, H, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2, A3]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B1, B2]} + A3: {label: A, column: 1, index: 2, next: [B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H2]} + H1: {label: H, column: 8, index: 0, next: [I1]} + H2: {label: H, column: 8, index: 1, next: [I1]} + I1: {label: I, column: 9, index: 0, next: [J1, J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [BB, BC, BB, CA, SS, SS] + B: [CV, BC, BC, CVL, DD, ASDG] + C: [CV, CV, BB, BC, DD, DD] + D: [BB, CV, BC, CA, CL, BG] + E: [CV, BC, BC, CA, SS, CL] + F: [BB, BB, BC, BC, CL, CL] + G: [BB, BB, BB, CA, CA, CA] + H: [BB, CV, BB, BC, CL, SS] + I: [BB, BBG, CV, CA, CA, CL] + J: [BB, CA, BBV, BBV, SS, BC] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-1.yaml new file mode 100644 index 00000000..03ca3337 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-1.yaml @@ -0,0 +1,35 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-5-1 +chapter: 5 +stage: 1 +key_points: [D, F, H] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B1]} + B1: {label: B, column: 2, index: 0, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + D2: {label: D, column: 4, index: 1, next: [E2, E3]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F1, F2]} + E3: {label: E, column: 5, index: 2, next: [F2]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2, H3]} + H1: {label: H, column: 8, index: 0, next: []} + H2: {label: H, column: 8, index: 1, next: []} + H3: {label: H, column: 8, index: 2, next: []} +enemy: + A: [DD, SS] + B: [CA, CL, AADG] + C: [BB, CA, DD] + D: [BC, CA, SS, SS] + E: [BB, CVL, CVL, DD, DD] + F: [CV, CV, CL, CL, DD, DD] + G: [BC, CVL, CA, CL, DD, AADG] + H: [AV, CV, CV, BB, DD, BBV] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-2.yaml new file mode 100644 index 00000000..94db5a5f --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-2.yaml @@ -0,0 +1,41 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-5-2 +chapter: 5 +stage: 2 +key_points: [D, G, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2, A3]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B1, B2]} + A3: {label: A, column: 1, index: 2, next: [B2]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1]} + H1: {label: H, column: 8, index: 0, next: [I1]} + I1: {label: I, column: 9, index: 0, next: [J1, J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [BB, CV, CV, CVL, CL, CL] + B: [CV, BB, BB, CA, CL, CL] + C: [BC, BB, BB, CA, CL, SS] + D: [BB, BB, BC, SS, SS, SS] + E: [BB, BB, BB, CL, CL, SS] + F: [BC, BC, BB, CA, CA, AADG] + G: [BC, CV, BB, BG, CL, CL] + H: [BB, BB, BB, ASDG, CL, CL] + I: [CV, BB, BB, CL, AADG, AADG] + J: [AV, AF, BC, BB, CLT, CLT] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-3.yaml new file mode 100644 index 00000000..e2a3b169 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-5-3.yaml @@ -0,0 +1,42 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-5-3 +chapter: 5 +stage: 3 +key_points: [C, G, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B2]} + B1: {label: B, column: 2, index: 0, next: [C1, C2]} + B2: {label: B, column: 2, index: 1, next: [C2, C3]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1]} + C3: {label: C, column: 3, index: 2, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + F3: {label: F, column: 6, index: 2, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1]} + H1: {label: H, column: 8, index: 0, next: [I1, I2]} + I1: {label: I, column: 9, index: 0, next: [J1, J2]} + I2: {label: I, column: 9, index: 1, next: [J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [CV, CV, BC, CA, CL, SS] + B: [BB, BB, BC, CL, CL, ASDG] + C: [BB, BBG, BC, BC, CL, CL] + D: [BB, BC, BC, CA, CA, SS] + E: [BB, BB, CVL, CVL, CL, AADG] + F: [CV, CVL, BB, BC, CA, CL] + G: [CV, CV, BB, BB, CA, CA] + H: [CV, BC, BB, BC, CA, CL] + I: [BB, BB, BB, BB, CL, CL] + J: [BB, BC, BC, CA, CA, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-1.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-1.yaml new file mode 100644 index 00000000..c49ca7fc --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-1.yaml @@ -0,0 +1,43 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-6-1 +chapter: 6 +stage: 1 +key_points: [B, G, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2]} + A1: {label: A, column: 1, index: 0, next: [B1, B2]} + A2: {label: A, column: 1, index: 1, next: [B2, B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1]} + B3: {label: B, column: 2, index: 2, next: [C1]} + C1: {label: C, column: 3, index: 0, next: [D1, D2]} + D1: {label: D, column: 4, index: 0, next: [E1]} + D2: {label: D, column: 4, index: 1, next: [E2]} + E1: {label: E, column: 5, index: 0, next: [F1, F2]} + E2: {label: E, column: 5, index: 1, next: [F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1, G2]} + F3: {label: F, column: 6, index: 2, next: [G2]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1]} + H1: {label: H, column: 8, index: 0, next: [I1, I2]} + I1: {label: I, column: 9, index: 0, next: [J1, J2]} + I2: {label: I, column: 9, index: 1, next: [J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [CL, CL] + B: [CLT, SS, SS] + C: [CA, CA, CA, CA] + D: [BC, CA, CA, CL, DD, AADG] + E: [CV, BC, CA, CA, DD, DD] + F: [CVL, BC, BC, CL, DD, SS] + G: [BB, BB, CA, CL, CL, AADG] + H: [BB, BB, BC, CVL, CL, DD] + I: [BC, BB, CV, CVL, CL, DD] + J: [AF, BB, DD, BB, BC, AV] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-2.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-2.yaml new file mode 100644 index 00000000..72f8fb04 --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-2.yaml @@ -0,0 +1,44 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-6-2 +chapter: 6 +stage: 2 +key_points: [C, H, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2, A3]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B2]} + A3: {label: A, column: 1, index: 2, next: [B3]} + B1: {label: B, column: 2, index: 0, next: [C1]} + B2: {label: B, column: 2, index: 1, next: [C1, C2]} + B3: {label: B, column: 2, index: 2, next: [C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D1]} + D1: {label: D, column: 4, index: 0, next: [E1]} + E1: {label: E, column: 5, index: 0, next: [F1, F2, F3]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G2]} + F3: {label: F, column: 6, index: 2, next: [G3]} + G1: {label: G, column: 7, index: 0, next: [H1]} + G2: {label: G, column: 7, index: 1, next: [H1, H2]} + G3: {label: G, column: 7, index: 2, next: [H2]} + H1: {label: H, column: 8, index: 0, next: [I1]} + H2: {label: H, column: 8, index: 1, next: [I1]} + I1: {label: I, column: 9, index: 0, next: [J1, J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [BB, BB, BC, CVL, CL, CL] + B: [CV, BC, BB, CA, CA, CL] + C: [BC, BB, BB, BC, CA, SS] + D: [CV, CV, BB, CA, CL, SS] + E: [BC, BC, CV, CV, CL, CL] + F: [BB, BB, BB, CA, CA, AADG] + G: [CV, BBG, CV, CA, CA, CA] + H: [BB, BB, CV, BC, CA, ASDG] + I: [CV, CVL, BB, BB, CA, SS] + J: [AV, CV, CV, AF, CVL, DD] diff --git a/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-3.yaml b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-3.yaml new file mode 100644 index 00000000..699f01cd --- /dev/null +++ b/autowsgr/data/map/decisive_battle/silent_warrior/EX-6-3.yaml @@ -0,0 +1,43 @@ +# Decisive battle node graph for Silent Warrior. +# Node IDs keep branch suffixes (A1/A2); position is intentionally absent until measured. +schema_version: 1 +event: silent_warrior +mapping_type: node_graph +map_id: EX-6-3 +chapter: 6 +stage: 3 +key_points: [D, G, J] +nodes: + '0': {label: '0', column: 0, index: 0, next: [A1, A2, A3]} + A1: {label: A, column: 1, index: 0, next: [B1]} + A2: {label: A, column: 1, index: 1, next: [B1]} + A3: {label: A, column: 1, index: 2, next: [B1]} + B1: {label: B, column: 2, index: 0, next: [C1, C2]} + C1: {label: C, column: 3, index: 0, next: [D1]} + C2: {label: C, column: 3, index: 1, next: [D2]} + D1: {label: D, column: 4, index: 0, next: [E1, E2]} + D2: {label: D, column: 4, index: 1, next: [E2, E3]} + E1: {label: E, column: 5, index: 0, next: [F1]} + E2: {label: E, column: 5, index: 1, next: [F1, F2]} + E3: {label: E, column: 5, index: 2, next: [F2]} + F1: {label: F, column: 6, index: 0, next: [G1]} + F2: {label: F, column: 6, index: 1, next: [G1]} + G1: {label: G, column: 7, index: 0, next: [H1, H2]} + H1: {label: H, column: 8, index: 0, next: [I1]} + H2: {label: H, column: 8, index: 1, next: [I2]} + I1: {label: I, column: 9, index: 0, next: [J1, J2]} + I2: {label: I, column: 9, index: 1, next: [J2, J3]} + J1: {label: J, column: 10, index: 0, next: []} + J2: {label: J, column: 10, index: 1, next: []} + J3: {label: J, column: 10, index: 2, next: []} +enemy: + A: [CV, BC, BB, BB, CL, AADG] + B: [BB, BC, BB, BB, CA, SS] + C: [BB, BC, BB, BG, BG, CL] + D: [BB, BC, BB, CV, ASDG, CL] + E: [CV, BB, CV, BB, CL, CL] + F: [BB, BC, BB, BB, CA, CA] + G: [BB, CV, BC, BB, SS, SS] + H: [CV, BB, BB, BC, CL, AADG] + I: [CV, CV, BB, BB, CL, BBG] + J: [BB, BB, CA, BBV, BBV, BC] diff --git a/autowsgr/emulator/controller/scrcpy.py b/autowsgr/emulator/controller/scrcpy.py index a2536319..6726e4d1 100644 --- a/autowsgr/emulator/controller/scrcpy.py +++ b/autowsgr/emulator/controller/scrcpy.py @@ -51,7 +51,6 @@ _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) diff --git a/autowsgr/image_resources/_lazy.py b/autowsgr/image_resources/_lazy.py index d60e2d1d..d718a626 100644 --- a/autowsgr/image_resources/_lazy.py +++ b/autowsgr/image_resources/_lazy.py @@ -69,7 +69,6 @@ def __init__( self._template: ImageTemplate | None = None def __set_name__(self, owner: type, name: str) -> None: - self._attr_name = name if self._name is None: self._name = name.lower() diff --git a/autowsgr/image_resources/combat.py b/autowsgr/image_resources/combat.py index 5619db46..5ef96617 100644 --- a/autowsgr/image_resources/combat.py +++ b/autowsgr/image_resources/combat.py @@ -41,16 +41,15 @@ class CombatTemplates: +===========================+======================================+ | FORMATION | combat/formation_540p.png | | SPOT_ENEMY | combat/spot_enemy_540p.png | - | RESULT | combat/result_540p.png | | FLAGSHIP_DAMAGE | combat/flagship_damage_540p.png | | PROCEED | combat/proceed_540p.png | | NIGHT_BATTLE | combat/night_battle_540p.png | | FIGHT_CONDITION | combat/fight_condition_540p.png | | BYPASS | combat/bypass_540p.png | - | RESULT_PAGE | combat/result_page_540p.png | | MISSILE_SUPPORT | combat/missile_support_540p.png | | MISSILE_ANIMATION | combat/missile_animation_540p.png | | FIGHT_PERIOD | combat/fight_period_540p.png | + | RESULT_PAGE | combat/result_page_540p.png | | GET_SHIP | combat/get_ship_540p.png | | GET_ITEM | combat/get_item_540p.png | | END_MAP_PAGE | combat/end_map_page_540p.png | @@ -62,16 +61,15 @@ class CombatTemplates: # ── 战斗阶段 ── FORMATION = LazyTemplate('combat/formation_540p.png', 'formation') SPOT_ENEMY = LazyTemplate('combat/spot_enemy_540p.png', 'spot_enemy') - RESULT = LazyTemplate('combat/result_540p.png', 'result') FLAGSHIP_DAMAGE = LazyTemplate('combat/flagship_damage_540p.png', 'flagship_damage') PROCEED = LazyTemplate('combat/proceed_540p.png', 'proceed') NIGHT_BATTLE = LazyTemplate('combat/night_battle_540p.png', 'night_battle') FIGHT_CONDITION = LazyTemplate('combat/fight_condition_540p.png', 'fight_condition') BYPASS = LazyTemplate('combat/bypass_540p.png', 'bypass') - RESULT_PAGE = LazyTemplate('combat/result_page_540p.png', 'result_page') MISSILE_SUPPORT = LazyTemplate('combat/missile_support_540p.png', 'missile_support') MISSILE_ANIMATION = LazyTemplate('combat/missile_animation_540p.png', 'missile_animation') FIGHT_PERIOD = LazyTemplate('combat/fight_period_540p.png', 'fight_period') + RESULT_PAGE = LazyTemplate('combat/result_page_540p.png', 'result_page') GET_SHIP = LazyTemplate('combat/get_ship_540p.png', 'get_ship') GET_ITEM = LazyTemplate('combat/get_item_540p.png', 'get_item') diff --git a/autowsgr/image_resources/keys.py b/autowsgr/image_resources/keys.py index 90cf90b2..8935609a 100644 --- a/autowsgr/image_resources/keys.py +++ b/autowsgr/image_resources/keys.py @@ -35,16 +35,15 @@ class TemplateKey(Enum): # ── 战斗阶段 ── FORMATION = 'formation' SPOT_ENEMY = 'spot_enemy' - RESULT = 'result' FLAGSHIP_DAMAGE = 'flagship_damage' PROCEED = 'proceed' NIGHT_BATTLE = 'night_battle' FIGHT_CONDITION = 'fight_condition' BYPASS = 'bypass' - RESULT_PAGE = 'result_page' MISSILE_SUPPORT = 'missile_support' MISSILE_ANIMATION = 'missile_animation' FIGHT_PERIOD = 'fight_period' + RESULT_PAGE = 'result_page' GET_SHIP = 'get_ship' GET_ITEM = 'get_item' GET_SHIP_OR_ITEM = 'get_ship_or_item' @@ -61,6 +60,7 @@ class TemplateKey(Enum): BATTLE_TIMES_EXCEED = 'battle_times_exceed' # ── 战果评级 ── + RESULT_GRADES = 'result_grades' GRADE_SS = 'grade_ss' GRADE_S = 'grade_s' GRADE_A = 'grade_a' @@ -87,16 +87,15 @@ def _build_map() -> dict[TemplateKey, list[ImageTemplate]]: return { TemplateKey.FORMATION: [T.FORMATION], TemplateKey.SPOT_ENEMY: [T.SPOT_ENEMY], - TemplateKey.RESULT: [T.RESULT], TemplateKey.FLAGSHIP_DAMAGE: [T.FLAGSHIP_DAMAGE], TemplateKey.PROCEED: [T.PROCEED], TemplateKey.NIGHT_BATTLE: [T.NIGHT_BATTLE], TemplateKey.FIGHT_CONDITION: [T.FIGHT_CONDITION], TemplateKey.BYPASS: [T.BYPASS], - TemplateKey.RESULT_PAGE: [T.RESULT_PAGE], TemplateKey.MISSILE_SUPPORT: [T.MISSILE_SUPPORT], TemplateKey.MISSILE_ANIMATION: [T.MISSILE_ANIMATION], TemplateKey.FIGHT_PERIOD: [T.FIGHT_PERIOD], + TemplateKey.RESULT_PAGE: [T.RESULT_PAGE], TemplateKey.GET_SHIP: [T.GET_SHIP], TemplateKey.GET_ITEM: [T.GET_ITEM], TemplateKey.GET_SHIP_OR_ITEM: [T.GET_SHIP, T.GET_ITEM], @@ -106,7 +105,8 @@ def _build_map() -> dict[TemplateKey, list[ImageTemplate]]: TemplateKey.END_EXERCISE_PAGE: [T.END_EXERCISE_PAGE], # 船坞已满 TemplateKey.DOCK_FULL: [T.DOCK_FULL], - # 战果评级 + # 战果评级 (SS~D 任一命中即为战果页; 评级字母仅战果页出现) + TemplateKey.RESULT_GRADES: T.Result.all_grades(), TemplateKey.GRADE_SS: [T.Result.SS], TemplateKey.GRADE_S: [T.Result.S], TemplateKey.GRADE_A: [T.Result.A], diff --git a/autowsgr/image_resources/ops.py b/autowsgr/image_resources/ops.py index 67d1112b..81da5351 100644 --- a/autowsgr/image_resources/ops.py +++ b/autowsgr/image_resources/ops.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING from autowsgr.image_resources._lazy import LazyTemplate, load_template +from autowsgr.image_resources.pages import Bath, CommonPage, Decisive, MainPage if TYPE_CHECKING: @@ -74,12 +75,6 @@ class Fight: """战斗相关模板 (ops 侧复用)。""" NIGHT_BATTLE = LazyTemplate('combat/night_battle_540p.png', 'night_battle') - RESULT_PAGE = LazyTemplate('combat/result_page_540p.png', 'result_page') - - @staticmethod - @lru_cache(maxsize=1) - def result_pages() -> list[ImageTemplate]: - return [load_template('combat/result_page_540p.png', name='result_page')] class FightResult: @@ -112,7 +107,9 @@ class Symbol: GET_SHIP = LazyTemplate('combat/get_ship_540p.png', 'symbol_get_ship') GET_ITEM = LazyTemplate('combat/get_item_540p.png', 'symbol_get_item') - CLICK_TO_CONTINUE = LazyTemplate('combat/result_540p.png', 'click_to_continue') + # MVP 徽章 (result_page_540p): 战果/经验结算页必有且不被遮挡, 比 + # "点击继续" 文字 (result_540p, 被舰船立绘遮挡致分数波动) 稳定 + CLICK_TO_CONTINUE = LazyTemplate('combat/result_page_540p.png', 'click_to_continue') class BackButton: @@ -141,44 +138,7 @@ class Error: # ═══════════════════════════════════════════════════════════════════════════════ -class Decisive: - """决战相关模板。""" - - USE_LAST_FLEET = LazyTemplate('decisive/use_last_fleet_540p.png', 'decisive_use_last_fleet') - """"使用上次舰队" 确认按钮 — 进入已有进度的章节时弹出。""" - - # ── 入口状态检测 (总览页) ── - - ENTRY_CANT_FIGHT = LazyTemplate( - 'decisive/entry_cant_fight_540p.png', 'decisive_entry_cant_fight' - ) - """入口状态: 无法出击。""" - - ENTRY_CHALLENGING = LazyTemplate( - 'decisive/entry_challenging_540p.png', 'decisive_entry_challenging' - ) - """入口状态: 挑战中 (当前章节正在进行)。""" - - ENTRY_REFRESHED = LazyTemplate('decisive/entry_refreshed_540p.png', 'decisive_entry_refreshed') - """入口状态: 已刷新 (有存档进度可继续)。""" - - ENTRY_REFRESH = LazyTemplate('decisive/entry_refresh_540p.png', 'decisive_entry_refresh') - """入口状态: 可重置 (显示"重置关卡")。""" - - @classmethod - def entry_status_templates(cls) -> list[ImageTemplate]: - """按 :class:`~autowsgr.types.DecisiveEntryStatus` 枚举顺序返回入口状态模板列表。 - - 索引 0-3 分别对应 CANT_FIGHT / CHALLENGING / REFRESHED / REFRESH。 - """ - return [ - cls.ENTRY_CANT_FIGHT, - cls.ENTRY_CHALLENGING, - cls.ENTRY_REFRESHED, - cls.ENTRY_REFRESH, - ] - - +# 页面/浮层识别模板已迁入 pages/ 子包 (CommonPage / MainPage / Decisive / Bath)。 class Templates: """图像模板统一入口。 @@ -202,3 +162,6 @@ class Templates: BackButton = BackButton Error = Error Decisive = Decisive + Page = CommonPage + MainPage = MainPage + Bath = Bath diff --git a/autowsgr/image_resources/pages/__init__.py b/autowsgr/image_resources/pages/__init__.py new file mode 100644 index 00000000..be5dd130 --- /dev/null +++ b/autowsgr/image_resources/pages/__init__.py @@ -0,0 +1,19 @@ +"""页面 / 浮层识别模板子包 — 按 UI 独立组织, 经 ``Templates`` 统一入口导出。 + +分类: + +- :class:`CommonPage` — 通用页面 (后院 / 食堂 / 出征准备 / 侧边栏) +- :class:`MainPage` — 主页面 (基础页面 + 登录/操作浮层) +- :class:`Decisive` — 决战 (入口状态 + 地图页 + 浮层) +- :class:`Bath` — 浴室 (页面 + 选择修理浮层) +""" + +from __future__ import annotations + +from autowsgr.image_resources.pages.bath import Bath +from autowsgr.image_resources.pages.common import CommonPage +from autowsgr.image_resources.pages.decisive import Decisive +from autowsgr.image_resources.pages.main_page import MainPage + + +__all__ = ['Bath', 'CommonPage', 'Decisive', 'MainPage'] diff --git a/autowsgr/image_resources/pages/bath.py b/autowsgr/image_resources/pages/bath.py new file mode 100644 index 00000000..e1052bf0 --- /dev/null +++ b/autowsgr/image_resources/pages/bath.py @@ -0,0 +1,29 @@ +"""浴室识别模板 — 浴室页面 + 选择修理浮层。 + +「选择修理」浮层打开时仍识别为浴室页面 (``BathPage.is_current_page`` 对基础页与 +浮层做 OR 组合)。 +""" + +from __future__ import annotations + +from autowsgr.image_resources._lazy import LazyTemplate + + +class Bath: + """浴室识别模板 (页面 + 浮层)。 + + .. note:: + + ``BATH`` 为 540p 基准 (classic ``bath_page``); ``CHOOSE_REPAIR`` 采集自 + 1280x720 实机 (``source_resolution=(1280, 720)``)。 + """ + + BATH = LazyTemplate('bath/bath_540p.png', 'page_bath') + """浴室页面特征 (classic ``bath_page``, 127x41 局部特征, 置信度 0.91)。""" + + CHOOSE_REPAIR = LazyTemplate( + 'bath/choose_repair_720p.png', + 'overlay_bath_choose_repair', + source_resolution=(1280, 720), + ) + """「选择受损舰船」浮层 (标题栏, 234x54)。""" diff --git a/autowsgr/image_resources/pages/common.py b/autowsgr/image_resources/pages/common.py new file mode 100644 index 00000000..b1deee0b --- /dev/null +++ b/autowsgr/image_resources/pages/common.py @@ -0,0 +1,31 @@ +"""通用页面识别模板 — 无专属浮层体系的独立页面。 + +迁移自 ``ops.py`` 原 ``Page`` 类。540p 基准, ``TM_CCOEFF_NORMED`` 置信度 >= 0.85, +与其他页面区分度高 (已做全截图交叉验证)。 +""" + +from __future__ import annotations + +from autowsgr.image_resources._lazy import LazyTemplate + + +class CommonPage: + """通用页面识别模板 (无专属浮层)。""" + + BACKYARD = LazyTemplate('page/backyard_540p.png', 'page_backyard') + """后院页面特征 (classic ``backyard_page``, 482x387 大区域模板, 区分度极高, 置信度 0.995)。""" + + CANTEEN = LazyTemplate('page/canteen_540p.png', 'page_canteen') + """食堂页面特征 (classic ``canteen_page``, 86x33 局部特征, 置信度 0.96)。""" + + BATTLE_PREP = LazyTemplate('page/fight_prepare_540p.png', 'page_battle_prep') + """出征准备页面特征 (classic ``fight_prepare_page``, 183x48 局部特征, 置信度 0.98)。""" + + SIDEBAR = LazyTemplate('page/sidebar_540p.png', 'page_sidebar') + """侧边栏页面特征 (classic ``options_page`` 大区域模板)。 + + .. note:: + + 正样本置信度约 0.86 (大模板含背景, 易随主题波动), 故识别阈值用 0.8; + 其他页最高仅 0.32, 区分度充足。 + """ diff --git a/autowsgr/image_resources/pages/decisive.py b/autowsgr/image_resources/pages/decisive.py new file mode 100644 index 00000000..f4a7bac5 --- /dev/null +++ b/autowsgr/image_resources/pages/decisive.py @@ -0,0 +1,106 @@ +"""决战相关识别模板 — 入口状态 + 地图页 + 浮层。 + +迁移并扩展自 ``ops.py`` 原 ``Decisive`` 类 (入口模板), 新增地图页与三种浮层 +(战备舰队获取 / 确认退出 / 选择前进点), 替代 ``decisive/overlay.py`` 的像素签名。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from autowsgr.image_resources._lazy import LazyTemplate + + +if TYPE_CHECKING: + from autowsgr.vision import ImageTemplate + + +class Decisive: + """决战识别模板 (入口 + 地图页 + 浮层)。 + + .. note:: + + 入口 / 地图页为 540p 基准; 三种浮层采集自 1280x720 实机 + (``source_resolution=(1280, 720)``)。 + """ + + # ── 入口 (总览页) ── + USE_LAST_FLEET = LazyTemplate('decisive/use_last_fleet_540p.png', 'decisive_use_last_fleet') + """"使用上次舰队" 确认按钮 — 进入已有进度的章节时弹出。""" + + ENTRY_CANT_FIGHT = LazyTemplate( + 'decisive/entry_cant_fight_540p.png', + 'decisive_entry_cant_fight', + ) + """入口状态: 无法出击。""" + + ENTRY_CHALLENGING = LazyTemplate( + 'decisive/entry_challenging_540p.png', + 'decisive_entry_challenging', + ) + """入口状态: 挑战中 (当前章节正在进行)。""" + + ENTRY_REFRESHED = LazyTemplate( + 'decisive/entry_refreshed_540p.png', + 'decisive_entry_refreshed', + ) + """入口状态: 已刷新 (有存档进度可继续)。""" + + ENTRY_REFRESH = LazyTemplate( + 'decisive/entry_refresh_540p.png', + 'decisive_entry_refresh', + ) + """入口状态: 可重置 (显示"重置关卡")。""" + + # ── 地图页 ── + MAP_PAGE = LazyTemplate('decisive/decisive_map_540p.png', 'decisive_map_page') + """决战地图页特征 (classic ``decisive_map_entrance``, 247x133)。""" + + FLEET_NAME = LazyTemplate( + 'decisive/fleet_name.png', + 'decisive_fleet_name', + source_resolution=(1280, 720), + ) + """决战出征准备页「主力决战舰队」标题。""" + + RESET_BUTTON = LazyTemplate( + 'decisive/reset_button.png', + 'decisive_reset_button', + source_resolution=(1280, 720), + ) + """决战总览页底部「重置关卡」入口按钮。""" + + # ── 浮层 ── + FLEET_ACQUISITION = LazyTemplate( + 'decisive/fleet_acq_720p.png', + 'decisive_fleet_acq', + source_resolution=(1280, 720), + ) + """战备舰队获取浮层标题 ROI 模板 (305x69)。""" + + CONFIRM_EXIT = LazyTemplate( + 'decisive/confirm_exit_720p.png', + 'decisive_confirm_exit', + source_resolution=(1280, 720), + ) + """确认退出浮层 (整浮层, 526x273)。""" + + ADVANCE_CHOICE = LazyTemplate( + 'decisive/advance_choice_720p.png', + 'decisive_advance_choice', + source_resolution=(1280, 720), + ) + """选择前进点浮层 (选项含涂字, 匹配率需实机验证, 281x191)。""" + + @classmethod + def entry_status_templates(cls) -> list[ImageTemplate]: + """按 :class:`~autowsgr.types.DecisiveEntryStatus` 枚举顺序返回入口状态模板列表。 + + 索引 0-3 分别对应 CANT_FIGHT / CHALLENGING / REFRESHED / REFRESH。 + """ + return [ + cls.ENTRY_CANT_FIGHT, + cls.ENTRY_CHALLENGING, + cls.ENTRY_REFRESHED, + cls.ENTRY_REFRESH, + ] diff --git a/autowsgr/image_resources/pages/main_page.py b/autowsgr/image_resources/pages/main_page.py new file mode 100644 index 00000000..9281dcff --- /dev/null +++ b/autowsgr/image_resources/pages/main_page.py @@ -0,0 +1,49 @@ +"""主页面识别模板 — 主页基础页面 + 登录/操作浮层。 + +主页面 (main page) 上可能叠加的浮层: 新闻公告 / 每日签到 / 活动预约 / 提督信息。 +浮层打开时仍识别为主页面 (``MainPage.is_current_page`` 对基础页与各浮层做 OR 组合)。 +""" + +from __future__ import annotations + +from autowsgr.image_resources._lazy import LazyTemplate + + +class MainPage: + """主页面模板 (基础页面 + 浮层)。 + + .. note:: + + ``MAIN`` 为 540p 基准 (classic ``main_page``); 浮层模板中 NEWS / SIGN / + USER_INFO 采集自 1280x720 实机 (``source_resolution=(1280, 720)``), BOOKING + 迁移自 classic 540p。``load_template`` 据此自动缩放到实际截图分辨率。 + """ + + # ── 基础页面 ── + MAIN = LazyTemplate('main_page/main_page_540p.png', 'page_main') + """主页面基础特征 (classic ``main_page``)。""" + + # ── 浮层 ── + NEWS = LazyTemplate( + 'main_page/news_720p.png', + 'overlay_news', + source_resolution=(1280, 720), + ) + """新闻公告浮层 (「今日不再显示」文字, 162x45)。""" + + SIGN = LazyTemplate( + 'main_page/sign_720p.png', + 'overlay_sign', + source_resolution=(1280, 720), + ) + """每日签到浮层 (「每日签到 >>」标题, 293x48)。""" + + BOOKING = LazyTemplate('main_page/booking_540p.png', 'overlay_booking') + """活动预约浮层 (classic 「是否跳转前往预定页面」, 199x29)。""" + + USER_INFO = LazyTemplate( + 'main_page/user_info_720p.png', + 'overlay_user_info', + source_resolution=(1280, 720), + ) + """提督信息浮层 (全屏个人资料「远征大成功」标签, 249x63)。""" diff --git a/autowsgr/infra/__init__.py b/autowsgr/infra/__init__.py index 27ddca1b..54f1a58b 100644 --- a/autowsgr/infra/__init__.py +++ b/autowsgr/infra/__init__.py @@ -25,6 +25,7 @@ EmulatorNotFoundError, GameError, ImageNotFoundError, + ManualRepairRequiredError, NavigationError, OCRError, PageNotFoundError, @@ -56,6 +57,7 @@ 'GameError', 'ImageNotFoundError', 'LogConfig', + 'ManualRepairRequiredError', 'NavigationError', 'NodeConfig', 'OCRConfig', diff --git a/autowsgr/infra/config.py b/autowsgr/infra/config.py index e0233eca..e4381337 100644 --- a/autowsgr/infra/config.py +++ b/autowsgr/infra/config.py @@ -384,11 +384,11 @@ class UserConfig(BaseModel): # 新版用 operation_delay_min/max 字段, 由 _apply_operation_delay 写回模块 # 全局 OPERATION_DELAY_MIN/MAX (供 operation_delay() 读取)。 # check_page 功能已由 launcher.ensure_ready 覆盖。 - operation_delay_min: float = 0.0 + operation_delay_min: float = Field(default=0.0, ge=0.0, allow_inf_nan=False) """UI 操作后随机延迟下界 (秒)。兼容层把 classic 的 delay 同时迁为本字段与 _max。""" - operation_delay_max: float = 0.0 + operation_delay_max: float = Field(default=0.0, ge=0.0, allow_inf_nan=False) """UI 操作后随机延迟上界 (秒)。""" - dock_full_destroy: bool = True + dock_full_destroy: bool = False """船坞满时自动清空""" repair_manually: bool = False """是否手动修理""" @@ -538,6 +538,8 @@ class NodeConfig(BaseModel): """是否前进""" proceed_stop: RepairMode | list[RepairMode] = RepairMode.severe_damage """达到指定破损状态时停止前进""" + grade: str = '' + """本节点要求的最低战果等级 (D/C/B/A/S/SS), 空 = 无要求""" class FightConfig(BaseModel): diff --git a/autowsgr/infra/exceptions.py b/autowsgr/infra/exceptions.py index 9c16d5cd..4ff25da9 100644 --- a/autowsgr/infra/exceptions.py +++ b/autowsgr/infra/exceptions.py @@ -112,6 +112,10 @@ def __init__(self, action_name: str, reason: str = '') -> None: super().__init__(msg) +class ManualRepairRequiredError(ActionFailedError): + """手动维修已处理, 当前整个任务必须终止。""" + + # ── 游戏逻辑异常 ── diff --git a/autowsgr/ops/__init__.py b/autowsgr/ops/__init__.py index edbe2596..c9a58028 100644 --- a/autowsgr/ops/__init__.py +++ b/autowsgr/ops/__init__.py @@ -66,7 +66,13 @@ from autowsgr.ops.expedition import collect_expedition # ── 导航 ── -from autowsgr.ops.navigate import goto_page, identify_current_page +from autowsgr.ops.navigate import ( + goto_bath_from_decisive_sortie, + goto_bath_from_event_sortie, + goto_bath_from_normal_sortie, + goto_page, + identify_current_page, +) # ── 常规战斗 ── from autowsgr.ops.normal_fight import ( @@ -119,6 +125,9 @@ 'ensure_game_ready', 'go_main_page', # 导航 + 'goto_bath_from_decisive_sortie', + 'goto_bath_from_event_sortie', + 'goto_bath_from_normal_sortie', 'goto_page', 'identify_current_page', 'is_game_running', diff --git a/autowsgr/ops/campaign.py b/autowsgr/ops/campaign.py index 8ce5449f..8b282681 100644 --- a/autowsgr/ops/campaign.py +++ b/autowsgr/ops/campaign.py @@ -185,6 +185,12 @@ def run(self) -> list[CombatResult]: ) results.append(result) _log.info('[OPS] 战役次数已用完') + # 主动退回地图页面: 此时仍停在出征准备页, 不退的话下一个 + # 任务的导航要靠漂移对账兜底 (栈预测与实际不符)。 + try: + BattlePreparationPage(self._ctx).go_back() + except NavigationError as e: + _log.warning('[OPS] 战役次数用尽后回退地图失败: {}', e) break # 同步战前信息到上下文 @@ -274,18 +280,17 @@ def _try_start_battle(self, page: BattlePreparationPage) -> bool: ``False`` 表示超时仍在当前页面 (可能是战役次数用尽)。 """ page.start_battle() - try: - wait_leave_page( - self._ctrl, - checker=BaseBattlePreparation.is_current_page, - timeout=1.5, - source=PageName.BATTLE_PREP, - target='combat', - ) - except NavigationError: - return False - else: - return True + # probe 模式: 次数用尽时超时是预期结果, 不抛 NavigationError + # (构造该异常会 ERROR 记录 + NavError 截图, 污染错误日志) + screen = wait_leave_page( + self._ctrl, + checker=BaseBattlePreparation.is_current_page, + timeout=1.5, + source=PageName.BATTLE_PREP, + target='combat', + probe=True, + ) + return screen is not None def _start_battle_with_retry(self, page: BattlePreparationPage) -> bool: """尝试出征,失败后重试一次。 diff --git a/autowsgr/ops/decisive/base.py b/autowsgr/ops/decisive/base.py index 07072115..88b4862d 100644 --- a/autowsgr/ops/decisive/base.py +++ b/autowsgr/ops/decisive/base.py @@ -36,6 +36,9 @@ class DecisiveBase: _map 决战地图页 UI _resume_mode 是否恢复进度模式 _has_chosen_fleet 是否已经历过战备舰队获取 + _fleet_overlay_enabled 是否允许检测战备舰队弹窗 + _force_fleet_scan 无购买项时强制扫描当前编队 + _full_recovery_check SL 后是否执行完整恢复检查 _wait_deadline 等待超时截止时间 _use_last_fleet_attempts 使用上次舰队尝试次数 @@ -84,8 +87,13 @@ def __init__( ) self._resume_mode: bool = False self._has_chosen_fleet: bool = False + self._fleet_overlay_enabled: bool = True + self._force_fleet_scan: bool = False + self._full_recovery_check: bool = False self._wait_deadline: float = 0.0 self._use_last_fleet_attempts: int = 0 + self._skip_advance_choice: bool = False + self._advance_source_node: str | None = None @property def state(self) -> DecisiveState: diff --git a/autowsgr/ops/decisive/config.py b/autowsgr/ops/decisive/config.py index a6e2aba4..4fe29450 100644 --- a/autowsgr/ops/decisive/config.py +++ b/autowsgr/ops/decisive/config.py @@ -1,140 +1,100 @@ -"""决战控制器配置与地图数据。""" +"""Decisive battle configuration and per-map data.""" from __future__ import annotations from functools import lru_cache from pathlib import Path +from typing import Any from autowsgr.infra.file_utils import load_yaml -from autowsgr.infra.logger import get_logger -_log = get_logger('decisive') +_MAP_DATA_ROOT = ( + Path(__file__).resolve().parents[2] / 'data' / 'map' / 'decisive_battle' / 'silent_warrior' +) -# ═══════════════════════════════════════════════════════════════════════════════ -# 地图静态数据 -# ═══════════════════════════════════════════════════════════════════════════════ +@lru_cache(maxsize=18) +def _load_map_data(chapter: int, stage: int) -> dict[str, Any]: + path = _MAP_DATA_ROOT / f'EX-{chapter}-{stage}.yaml' + data = load_yaml(path) + if not isinstance(data, dict) or not isinstance(data.get('nodes'), dict): + raise TypeError(f'invalid decisive map data: {path}') + return data -# 数据来源: autowsgr_legacy/data/map/decisive_battle/enemy_spec.yaml -# map_end[chapter][stage] → 该小关最后一个节点字母 -# chapter 索引 0 为占位; 有效章节 1-6; stage 索引 0 为占位, 有效小关 1-3。 -_MAP_END: list[str] = [ - '', # 0: 占位 - ' FHH', # chapter 1: stage1=F, stage2=H, stage3=H - ' FHH', # chapter 2 - ' HHJ', # chapter 3 - ' HHJ', # chapter 4 - ' HJJ', # chapter 5 - ' JJJ', # chapter 6 -] -# key_points[chapter][stage] → 需要夜战的关键节点字母集合 -_KEY_POINTS: dict[int, list[str]] = { - 1: ['', 'BDF', 'BFH', 'DHJ'], - 2: ['', 'CFH', 'BFH', 'DHJ'], - 3: ['', 'CFH', 'BFH', 'DHJ'], - 4: ['', 'CFH', 'BFH', 'DHJ'], - 5: ['', 'DFH', 'DGJ', 'CGJ'], - 6: ['', 'BGJ', 'CHJ', 'DGJ'], -} +def _node_label(node_id: str, node: dict[str, Any]) -> str: + return str(node.get('label', node_id)).upper() -@lru_cache(maxsize=1) -def _load_enemy_spec_data() -> dict: - """加载决战 enemy_spec.yaml 数据。""" - data_path = ( - Path(__file__).resolve().parents[2] / 'data' / 'map' / 'decisive_battle' / 'enemy_spec.yaml' - ) - return load_yaml(data_path) +def _leftmost_node_ids(data: dict[str, Any]) -> dict[str, str]: + """Return the node IDs on the route that always takes the first edge.""" + nodes = data['nodes'] + result: dict[str, str] = {} + current = '0' + visited: set[str] = set() + while current not in visited: + visited.add(current) + node = nodes.get(current) + if not isinstance(node, dict): + break + label = _node_label(current, node) + result.setdefault(label, current) + next_nodes = node.get('next', []) + if not next_nodes: + break + current = str(next_nodes[0]) + return result class MapData: - """决战地图静态数据查询。 - - 封装 ``map_end`` 与 ``key_points``,提供按 *chapter / stage* 查询的方法。 - """ + """Query the normalized per-EX decisive map files.""" @staticmethod def get_stage_end_node(chapter: int, stage: int) -> str: - """获取指定章节、小关的终止节点字母。 - - Parameters - ---------- - chapter: - 章节编号 (1-6)。 - stage: - 小关编号 (1-3)。 - - Returns - ------- - str - 终止节点字母 (如 ``'H'``, ``'J'``)。 - 若 chapter/stage 超出范围,返回 ``'J'`` 作为安全回退。 - """ - if 1 <= chapter < len(_MAP_END) and 1 <= stage <= 3: - return _MAP_END[chapter][stage] - raise ValueError(f'无效章节/小关: chapter={chapter}, stage={stage}') + data = _load_map_data(chapter, stage) + terminal_labels = { + _node_label(node_id, node) + for node_id, node in data['nodes'].items() + if isinstance(node, dict) and node_id != '0' and not node.get('next', []) + } + if len(terminal_labels) != 1: + raise ValueError(f'invalid decisive terminal nodes: chapter={chapter}, stage={stage}') + return terminal_labels.pop() @staticmethod def is_stage_end(chapter: int, stage: int, node: str) -> bool: - """判断当前节点是否为该小关的终止节点。 - - Parameters - ---------- - chapter: - 章节编号 (1-6)。 - stage: - 小关编号 (1-3)。 - node: - 当前节点字母 (如 ``'A'``, ``'H'``)。 - """ - return node == MapData.get_stage_end_node(chapter, stage) + return node.upper() == MapData.get_stage_end_node(chapter, stage) @staticmethod def get_key_points(chapter: int, stage: int) -> set[str]: - """获取指定章节、小关的关键节点集合 (需夜战)。 - - Parameters - ---------- - chapter: - 章节编号 (4-6)。 - stage: - 小关编号 (1-3)。 - - Returns - ------- - set[str] - 关键节点字母集合;未找到时返回空集。 - """ - kps = _KEY_POINTS.get(chapter, []) - if 1 <= stage < len(kps): - return set(kps[stage]) - return set() + data = _load_map_data(chapter, stage) + return {str(node).upper() for node in data.get('key_points', [])} @staticmethod def is_key_point(chapter: int, stage: int, node: str) -> bool: - """判断当前节点是否为关键点 (需夜战)。""" - return node in MapData.get_key_points(chapter, stage) + return node.upper() in MapData.get_key_points(chapter, stage) @staticmethod def get_enemy(chapter: int, stage: int, node: str) -> list[str]: - """获取指定章节/小关/节点的敌方编成。""" - try: - data = _load_enemy_spec_data() - enemy_data = data.get('enemy', []) - chapter_data = enemy_data[chapter] - stage_data = chapter_data[stage] - node_data = stage_data.get(node.upper()) - if isinstance(node_data, list): - return [str(x) for x in node_data if x] - except Exception: - _log.debug( - '[决战] 敌方规格数据查询失败: chapter={}, stage={}, node={}', - chapter, - stage, - node, - exc_info=True, - ) + data = _load_map_data(chapter, stage) + enemy = data.get('enemy', {}).get(node.upper(), []) + return [str(value) for value in enemy if value] + + @staticmethod + def get_leftmost_choices(chapter: int, stage: int, source_node: str) -> list[str]: + """Return successors from the route node for an always-left path.""" + data = _load_map_data(chapter, stage) + nodes = data['nodes'] + source = source_node.upper() + source_id = '0' if source in {'', 'U', '0'} else _leftmost_node_ids(data).get(source) + if source_id is None: + candidates = [ + (node.get('column', 0), node.get('index', 0), node_id) + for node_id, node in nodes.items() + if isinstance(node, dict) and _node_label(node_id, node) == source + ] + source_id = min(candidates)[2] if candidates else None + if source_id is None: return [] - return [] + return [str(node_id) for node_id in nodes[source_id].get('next', [])] diff --git a/autowsgr/ops/decisive/controller.py b/autowsgr/ops/decisive/controller.py index dc8ddceb..910b17b4 100644 --- a/autowsgr/ops/decisive/controller.py +++ b/autowsgr/ops/decisive/controller.py @@ -72,13 +72,16 @@ class DecisiveController(DecisivePhaseHandlers, DecisiveChapterOps): # ── 主入口 ──────────────────────────────────────────────────────────── - def run(self) -> DecisiveResult: + def run(self, *, full_recovery_check: bool = False) -> DecisiveResult: """执行一轮完整决战(3 个小关)。""" _log.info('[决战] 开始第 {} 章决战', self._config.chapter) self._state.reset() # 默认进入恢复模式,扫描舰船进度 self._resume_mode = True self._has_chosen_fleet = False + self._fleet_overlay_enabled = True + self._force_fleet_scan = full_recovery_check + self._full_recovery_check = full_recovery_check self._prepare_entry_state() self._state.phase = DecisivePhase.ENTER_MAP try: @@ -138,6 +141,7 @@ def _main_loop(self) -> DecisiveResult: self._execute_retreat() self._state.reset() self._state.phase = DecisivePhase.ENTER_MAP + self._force_fleet_scan = False continue if phase == DecisivePhase.LEAVE: diff --git a/autowsgr/ops/decisive/handlers.py b/autowsgr/ops/decisive/handlers.py index e7fd10c1..b3c012da 100644 --- a/autowsgr/ops/decisive/handlers.py +++ b/autowsgr/ops/decisive/handlers.py @@ -17,12 +17,15 @@ import time from typing import TYPE_CHECKING -import cv2 - +from autowsgr.combat.actions import click_result from autowsgr.combat.engine import run_combat from autowsgr.combat.plan import CombatMode, CombatPlan, NodeDecision +from autowsgr.constants import DECISIVE_SKILL_NAMES from autowsgr.infra.logger import get_logger from autowsgr.ops.decisive.base import DecisiveBase +from autowsgr.ops.decisive.config import MapData +from autowsgr.ops.navigate import goto_bath_from_decisive_sortie +from autowsgr.ops.repair import repair_manual_targets_in_bath from autowsgr.types import ( ConditionFlag, DecisiveEntryStatus, @@ -32,11 +35,14 @@ ) from autowsgr.ui import RepairStrategy from autowsgr.ui.decisive import DecisiveBattlePreparationPage +from autowsgr.ui.decisive.overlay import ADVANCE_CHOICE_ROI, ADVANCE_CHOICE_THREE_ROI if TYPE_CHECKING: import numpy as np + from autowsgr.vision import ROI + _log = get_logger('ops.decisive') @@ -84,6 +90,32 @@ def _sync_ship_states(self) -> None: if name and stat != ShipDamageState.NO_SHIP: self._ctx.update_ship_damage(name, stat) + def _advance_choices(self) -> list[str]: + source_node = getattr(self, '_advance_source_node', None) + if source_node is None: + source_node = self._state.node if self._state.node != 'U' else '0' + choices = MapData.get_leftmost_choices( + self._config.chapter, + self._state.stage, + source_node, + ) + _log.debug( + '[决战] 路线选择: source={} choices={}', + source_node, + choices, + ) + return choices + + def _advance_choice_roi(self) -> ROI | None: + if getattr(self, '_advance_source_node', None) is None and self._state.node == 'U': + return None + choice_count = len(self._advance_choices()) + if choice_count == 3: + return ADVANCE_CHOICE_THREE_ROI + if choice_count == 2: + return ADVANCE_CHOICE_ROI + return None + """决战阶段处理器子类。 包含所有 ``_handle_`` 方法: @@ -137,10 +169,17 @@ def _handle_enter_map(self) -> None: _log.info('[决战] 入口状态: {}', entry_status.value) - self._state.stage = self._battle_page.detect_stage( + stage = self._battle_page.detect_stage( self._ctrl.screenshot(), self._config.chapter, ) + if stage == 0: + raise RuntimeError(f'决战 Ex-{self._config.chapter}: 无法识别有效小节') + if stage is None: + _log.info('[决战] Ex-{} 三个小节均已完成,结束本轮', self._config.chapter) + self._state.phase = DecisivePhase.CHAPTER_CLEAR + return + self._state.stage = stage if self._config.chapter == 1: self._resume_mode = False _log.info( @@ -149,36 +188,42 @@ def _handle_enter_map(self) -> None: ) self._battle_page.click_enter_map() self._use_last_fleet_attempts = 0 + self._skip_advance_choice = False + self._advance_source_node = None self._wait_deadline = time.monotonic() + 15.0 self._state.phase = DecisivePhase.WAITING_FOR_MAP def _handle_waiting_for_map(self) -> None: """等待地图页加载: 单次截图检测 → 转到对应阶段或继续等待。""" - screen = self._ctrl.screenshot() - phase = self._map.detect_decisive_phase(screen) + wait_for_advance = not self._skip_advance_choice + full_recovery_check = getattr(self, '_full_recovery_check', False) + entry_kwargs = { + 'wait_for_use_last': self._use_last_fleet_attempts == 0, + 'wait_for_advance': wait_for_advance, + 'wait_for_fleet': self._fleet_overlay_enabled + and ( + full_recovery_check + or self._advance_source_node is not None + or self._skip_advance_choice + ), + 'timeout': 3.0, + 'interval': 0.2, + } + if (advance_choice_roi := self._advance_choice_roi()) is not None: + entry_kwargs['advance_choice_roi'] = advance_choice_roi + phase = self._map.wait_for_entry_phase( + **entry_kwargs, + ) + self._skip_advance_choice = False - # Ex-1 首次进入第 1 小节时,理论上应先经历一次战备舰队获取。 - # 若此时尚未进入过 CHOOSE_FLEET,却稳定识别成 PREPARE_COMBAT, - # 则将其视为购买界面漏判并自动修正到 CHOOSE_FLEET。 - # 注意:暂离后重进时 node='U',此时舰标已在地图上,不应修正到 CHOOSE_FLEET if ( - phase == DecisivePhase.PREPARE_COMBAT - and self._state.stage == 1 - and not self._has_chosen_fleet + phase is DecisivePhase.PREPARE_COMBAT + and wait_for_advance + and self._advance_source_node is None + and not full_recovery_check ): - if self._state.node != 'U': - _log.warning('[决战] 首进第 1 小节将 PREPARE_COMBAT 修正为 CHOOSE_FLEET') - self._state.phase = DecisivePhase.CHOOSE_FLEET - return - # node == 'U' 时,通过舰标检测区分暂离重进与 overlay 延迟加载 - bgr = cv2.cvtColor(screen, cv2.COLOR_RGB2BGR) - icon_x = self._map._locate_ship_icon(bgr) - if icon_x is None: - _log.warning( - '[决战] 首进第 1 小节未检测到舰标,将 PREPARE_COMBAT 修正为 CHOOSE_FLEET' - ) - self._state.phase = DecisivePhase.CHOOSE_FLEET - return + _log.info('[决战] 未检测到前进点弹窗,按暂离恢复处理,关闭战备浮窗识别') + self._fleet_overlay_enabled = False if phase is not None: self._state.phase = phase @@ -213,13 +258,15 @@ def _handle_dock_full(self) -> None: def _handle_choose_fleet(self) -> None: """战备舰队获取:OCR 识别选项 → 购买决策 → 关闭弹窗。""" - self._has_chosen_fleet = True + self._has_chosen_fleet = False + self._force_fleet_scan = False _log.info('[决战] 战备舰队获取') screen, score, selections = self._recognize_fleet_options_with_retry( fallback_score=self._state.score, ) self._state.score = score or self._state.score + to_buy: list[str] = [] if selections: first_node = self._state.is_begin() @@ -242,28 +289,58 @@ def _handle_choose_fleet(self) -> None: first_node=first_node, ) - _log.info('[决战] 选择购买: {}', to_buy) - for name in to_buy: - sel = selections[name] - self._map.buy_fleet_option(sel.click_position) - if name not in {'长跑训练', '肌肉记忆', '黑科技'}: - self._state.ships.add(name) + _log.info('[决战] 选择购买: {}', to_buy) + for name in to_buy: + sel = selections[name] + self._map.buy_fleet_option(sel.click_position) + if name not in {'长跑训练', '肌肉记忆', '黑科技'}: + self._state.ships.add(name) - self._state.phase = DecisivePhase.PREPARE_COMBAT if not self._map.close_fleet_overlay(): - _log.info('[决战] 关闭决战选船界面失败, 选择第一艘后撤退') - self._state.phase = DecisivePhase.RETREAT - _, first_value = next(iter(selections.items())) - self._map.buy_fleet_option(first_value.click_position) + if to_buy: + _log.warning('[决战] 已购买配置舰船但关闭选船界面失败, 准备撤退') + self._state.phase = DecisivePhase.RETREAT + return + + fallback_options = [ + (name, selection) + for name, selection in selections.items() + if name not in DECISIVE_SKILL_NAMES + ] + if not fallback_options: + raise TimeoutError('未购买配置舰船且没有可用的舰船兜底卡') + + fallback_name, fallback = min(fallback_options, key=lambda item: item[1].cost) + _log.info( + '[决战] 首次关闭失败且未购买配置舰船, 选择最低费兜底舰船: {} (费用={})', + fallback_name, + fallback.cost, + ) + self._map.buy_fleet_option(fallback.click_position) + self._state.ships.add(fallback_name) if not self._map.close_fleet_overlay(): - raise RuntimeError('关闭决战选船界面失败') + raise TimeoutError('选择兜底舰船后仍无法关闭战备舰队弹窗') + self._state.phase = DecisivePhase.RETREAT + return + + if not to_buy: + _log.info('[Decisive] defer current-fleet sufficiency check to preparation') + self._force_fleet_scan = True + + self._has_chosen_fleet = True + self._state.phase = DecisivePhase.PREPARE_COMBAT def _handle_advance_choice(self) -> None: """选择前进点。""" _log.info('[决战] 选择前进点') - choice_idx = self._logic.get_advance_choice([]) - self._map.select_advance_card(choice_idx) - self._state.phase = DecisivePhase.CHOOSE_FLEET + if (advance_choice_roi := self._advance_choice_roi()) is None: + self._map.select_advance_card(0) + else: + self._map.select_advance_card(0, advance_choice_roi=advance_choice_roi) + self._advance_source_node = None + self._wait_deadline = time.monotonic() + 10.0 + self._skip_advance_choice = True + self._state.phase = DecisivePhase.WAITING_FOR_MAP # ── 战斗 ────────────────────────────────────────────────────────────── @@ -274,7 +351,11 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 # 某些情况下地图页识别会先于 overlay 稳定,导致实际上仍停留在 # 「战备舰队获取 / 前进点选择」时就误入 PREPARE_COMBAT。 # 这里补一次即时探测,优先回到正确阶段,避免后续直接点“编队”超时。 - overlay_phase = self._map.detect_decisive_phase(screen) + overlay_phase = self._map.detect_decisive_phase( + screen, + advance_choice_roi=self._advance_choice_roi(), + allow_fleet_overlay=self._fleet_overlay_enabled, + ) if overlay_phase in (DecisivePhase.CHOOSE_FLEET, DecisivePhase.ADVANCE_CHOICE): _log.info('[决战] 出征准备前检测到 overlay,切回阶段: {}', overlay_phase.name) self._state.phase = overlay_phase @@ -288,6 +369,12 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 self._state.phase = DecisivePhase.CHOOSE_FLEET return self._state.node = recognized_node + _log.info( + '[决战] 当前进入为章节 {} 小节 {} 的 {} 列', + self._config.chapter, + self._state.stage, + recognized_node, + ) _log.info( '[决战] 出征准备 (小关 {} 节点 {})', self._state.stage, @@ -296,7 +383,8 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 # ── 恢复模式检测 ───────────────────────────────────────────── # 恢复模式逻辑修改,默认进入恢复模式,如果是首节点,则不进入恢复模式 - if self._state.is_begin(): + full_recovery_check = getattr(self, '_full_recovery_check', False) + if self._state.is_begin() and not self._force_fleet_scan and not full_recovery_check: self._resume_mode = False _log.info( '[决战] 检测到恢复模式 (节点={}, has_chosen_fleet={})', @@ -332,15 +420,20 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 # ── 恢复模式: 扫描当前舰队与可用舰船 ───────────────────────── # 对齐 legacy: if fleet.empty() and not is_begin(): _check_fleet() - if self._resume_mode: + formation_ready = False + if self._resume_mode or self._force_fleet_scan or full_recovery_check: _log.info('[决战] 恢复模式: 扫描当前舰队') - fleet, damage, all_ships = self._map.check_fleet() + fleet, damage, all_ships = self._map.check_fleet( + scan_ship_pool=full_recovery_check, + ) + formation_ready = True self._state.ship_stats = [damage.get(i, ShipDamageState.NORMAL) for i in range(6)] - self._state.ships = all_ships + self._state.ships.update(all_ships) # 将编队成员写入 state.fleet[1:] for i, name in enumerate(fleet): if i < 6: self._state.fleet[i + 1] = name or '' + self._force_fleet_scan = False self._sync_ship_states() self._resume_mode = False # 扫描完成后退出恢复模式 @@ -350,8 +443,9 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 self._state.phase = DecisivePhase.RETREAT return - self._map.enter_formation() - time.sleep(0.5) # 等待编队页加载完成(对齐 check_fleet 的做法) + if not formation_ready: + self._map.enter_formation() + time.sleep(0.5) # 等待编队页加载完成(对齐 check_fleet 的做法) page = DecisiveBattlePreparationPage(self._ctx, self._config, self._ocr) current_fleet = self._state.fleet[:] @@ -364,7 +458,25 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 strategy = ( RepairStrategy.MODERATE if self._config.repair_level <= 1 else RepairStrategy.SEVERE ) - page.apply_repair(strategy, repair_manually=not self._config.use_quick_repair) + if self._config.use_quick_repair: + page.apply_repair(strategy) + else: + + def manual_repair_action(positions: list[int]) -> None: + targets = [ + self._state.fleet[position + 1] + for position in positions + if 0 <= position + 1 < len(self._state.fleet) + and self._state.fleet[position + 1] + ] + goto_bath_from_decisive_sortie(self._ctx) + repair_manual_targets_in_bath(self._ctx, targets) + + page.apply_repair( + strategy, + repair_manually=True, + manual_repair_action=manual_repair_action, + ) screen = self._ctrl.screenshot() damage = page.detect_ship_damage(screen) @@ -373,6 +485,7 @@ def _handle_prepare_combat(self) -> None: # noqa: PLR0912, PLR0915 page.start_battle() time.sleep(1.0) + self._full_recovery_check = False self._state.phase = DecisivePhase.IN_COMBAT def _handle_combat(self) -> None: @@ -405,6 +518,11 @@ def _handle_combat(self) -> None: self._state.ship_stats, ) + if result.flag == ConditionFlag.OPERATION_SUCCESS: + _log.info('[决战] 战果识别成功,点击继续结束结算页') + click_result(self._ctrl) + time.sleep(0.3) + # 处理战斗结果标志 if result.flag == ConditionFlag.DOCK_FULL: _log.warning('[决战] 战斗中检测到船坞已满,转到 DOCK_FULL 阶段处理') @@ -432,6 +550,7 @@ def _handle_node_result(self) -> None: # 先通过逻辑判断小关是否结束 if self._logic.is_stage_end(): + self._fleet_overlay_enabled = False _log.info( '[决战] 小关 {} 终止节点 {} 已到达', self._state.stage, @@ -445,7 +564,10 @@ def _handle_node_result(self) -> None: # 注意:战斗结束后可能出现 ADVANCE_CHOICE/CHOOSE_FLEET overlay, # 此时不应调用 recognize_node(),因为舰标尚未出现。 # 恢复模式(暂离后再进)时,节点识别在 _handle_prepare_combat 中进行。 - expected_node = chr(ord(self._state.node) + 1) + current_node = self._state.node + self._fleet_overlay_enabled = True + self._advance_source_node = current_node + expected_node = chr(ord(current_node) + 1) self._state.node = expected_node _log.debug('[决战] 节点递进: {} -> {}', chr(ord(expected_node) - 1), expected_node) @@ -456,7 +578,10 @@ def _handle_node_result(self) -> None: deadline = time.monotonic() + self._POST_COMBAT_TIMEOUT while time.monotonic() < deadline: time.sleep(self._POST_COMBAT_INTERVAL) - phase = self._map.detect_decisive_phase() + phase = self._map.detect_decisive_phase( + advance_choice_roi=self._advance_choice_roi(), + allow_fleet_overlay=self._fleet_overlay_enabled, + ) if phase == DecisivePhase.PREPARE_COMBAT: continue if phase is not None: @@ -464,19 +589,25 @@ def _handle_node_result(self) -> None: self._state.phase = phase return - # 超时回退到 PREPARE_COMBAT + # 超时后继续等待入口 overlay,避免在未知画面上直接点击编队。 _log.warning( - '[决战] 战后状态检测超时 ({:.0f}s), 回退到 PREPARE_COMBAT', + '[决战] 战后状态检测超时 ({:.0f}s), 继续等待 overlay', self._POST_COMBAT_TIMEOUT, ) - self._state.phase = DecisivePhase.PREPARE_COMBAT + self._wait_deadline = time.monotonic() + 10.0 + self._state.phase = DecisivePhase.WAITING_FOR_MAP def _handle_stage_clear(self) -> None: """小关通关:确认弹窗 → 收集掉落 → 下一小关或大关。""" _log.info('[决战] 小关 {} 通关!', self._state.stage) collected = self._map.confirm_stage_clear() - self._state.node = 'A' + # The next subsection must re-anchor from the live map. Do not carry + # A across the stage boundary or route selection will use A as source + # instead of the synthetic entry node 0. + self._state.node = 'U' + self._advance_source_node = None self._resume_mode = True + self._fleet_overlay_enabled = True if collected: _log.info('[决战] 获得 {} 个掉落: {}', len(collected), collected) @@ -492,9 +623,11 @@ def _execute_retreat(self) -> None: _log.info('[决战] 执行撤退') self._map.open_retreat_dialog() self._map.confirm_retreat() + self._fleet_overlay_enabled = True def _execute_leave(self) -> None: """执行暂离操作。""" _log.info('[决战] 执行暂离') self._map.open_retreat_dialog() self._map.confirm_leave() + self._fleet_overlay_enabled = False diff --git a/autowsgr/ops/decisive/logic.py b/autowsgr/ops/decisive/logic.py index a21ad0ea..366b9a5d 100644 --- a/autowsgr/ops/decisive/logic.py +++ b/autowsgr/ops/decisive/logic.py @@ -6,6 +6,7 @@ from __future__ import annotations +from itertools import combinations from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger @@ -101,37 +102,54 @@ def choose_ships( list[str] 选中购买的名称列表(按决策顺序)。 """ - fleet_count = sum(1 for s in self.state.fleet[1:] if s) + owned = {name for name in getattr(self.state, 'ships', set()) if _is_ship(name)} score = self.state.score - - if fleet_count <= 1: - candidates = self.config.level1 - elif fleet_count < 6: - candidates = [e for e in self._level2_full if _is_ship(e)] - elif not {s for s in self.state.fleet[1:] if s}.issubset(self._level1_set): - candidates = self.config.level1 - else: - candidates = self.config.level1 + [e for e in self._level2_full if not _is_ship(e)] - - lim = 6 if fleet_count < 6 else score - lim = score if fleet_count == 0 else lim - result: list[str] = [] - for target in candidates: - if target in selections: - sel = selections[target] - if score >= sel.cost and sel.cost <= lim: - score -= sel.cost - result.append(target) - - # 第一节点没选上Lv1,也购买Lv2舰船 - if first_node: - for target in set(self._level2_full) - self._level1_set: - if target in selections: - sel = selections[target] - if score >= sel.cost and sel.cost <= lim: - score -= sel.cost - result.append(target) - return result + primary = [name for name in self.config.level1 if _is_ship(name)] + backup = [ + name for name in self.config.level2 if _is_ship(name) and name not in self._level1_set + ] + ordered = [*primary, *backup] + + target_count = 2 if first_node and len(owned) < 2 else 6 + missing_count = max(0, target_count - len(owned)) + candidates = [name for name in ordered if name in selections and name not in owned] + + # Maximize new ships first, then primary ships, then preserve resources. + selected: list[str] = [] + if missing_count and candidates: + best_names: tuple[str, ...] = () + best_rank = (-1, -1, 0) + for size in range(1, min(missing_count, len(candidates)) + 1): + for names in combinations(candidates, size): + cost = sum(selections[name].cost for name in names) + if cost > score: + continue + primary_count = sum(name in self._level1_set for name in names) + rank = (size, primary_count, -cost) + if rank > best_rank: + best_names = names + best_rank = rank + selected.extend(best_names) + score -= sum(selections[name].cost for name in best_names) + owned.update(best_names) + + # Once six ships exist, add missing primary ships before buying upgrades. + if not first_node and len(owned) >= 6: + for name in primary: + if name not in owned and name in selections and score >= selections[name].cost: + selected.append(name) + score -= selections[name].cost + owned.add(name) + + # Upgrade only primary ships already in the acquired pool. + for name in primary: + if name in owned and name in selections and name not in selected: + if score < selections[name].cost: + continue + selected.append(name) + score -= selections[name].cost + + return selected # ── 状态判断 ─────────────────────────────────────────────────────── @@ -157,8 +175,8 @@ def should_repair(self) -> bool: def is_stage_end(self, node: str | None = None) -> bool: """判断当前节点是否为该小关的终止节点。 - 根据 ``MapData`` 中的 ``map_end`` 数据判断, - 替代原先硬编码 ``> "J"`` 的逻辑。 + 根据 per-EX 地图数据中的终点节点判断, + 替代原先的静态终点常量。 Parameters ---------- @@ -226,14 +244,20 @@ def get_best_fleet(self) -> list[str]: 长度 7 的列表:索引 0 留空,1-6 为各位置舰船名。 """ ships = self.state.ships + current_fleet = {name for name in self.state.fleet[1:] if name} best: list[str] = [''] _log.debug('[决战] 当前舰船: {}', ships) for ship in self.config.level1: - if ship in ships and self._is_available(ship) and len(best) < 7: + if ship in ships and (ship in current_fleet or self._is_available(ship)) and len(best) < 7: best.append(ship) for ship in self.config.level2: - if ship in ships and ship not in best and self._is_available(ship) and len(best) < 7: + if ( + ship in ships + and ship not in best + and (ship in current_fleet or self._is_available(ship)) + and len(best) < 7 + ): best.append(ship) for flag_ship in self.config.flagship_priority: @@ -250,22 +274,6 @@ def get_best_fleet(self) -> list[str]: # ── 路径选择 ─────────────────────────────────────────────────────── - def get_advance_choice(self, options: list[str]) -> int: # noqa: ARG002 - """选择前进点索引。 - - Parameters - ---------- - options: - 可选前进点列表 (如 ``["A1", "A2"]``)。 - - Returns - ------- - int - 选中选项的索引 (0-based)。 - """ - # TODO: 根据地图数据和关键节点信息做出更智能的选择 - return 0 - def get_formation(self) -> Formation: """根据当前节点敌方编成动态选择阵型。 diff --git a/autowsgr/ops/destroy.py b/autowsgr/ops/destroy.py index 43959bca..91515d24 100644 --- a/autowsgr/ops/destroy.py +++ b/autowsgr/ops/destroy.py @@ -4,6 +4,9 @@ ``ship_types=None`` 表示不过滤舰种,全部解装; 传入舰种列表则只解装指定舰种。 + +船坞满弹窗的「解装」按钮可直达解体标签 (不绕主菜单导航), +见 :func:`destroy_ships_auto` 的 ``from_dialog`` 参数。 """ from __future__ import annotations @@ -13,6 +16,8 @@ from autowsgr.infra.logger import get_logger from autowsgr.ops.navigate import goto_page from autowsgr.types import DestroyShipWorkMode, PageName, ShipType +from autowsgr.ui.build_page import BuildPage, BuildTab +from autowsgr.ui.utils import click_and_wait_for_page if TYPE_CHECKING: @@ -20,6 +25,12 @@ _log = get_logger('ops') +CLICK_DOCK_DIALOG_DESTROY: tuple[float, float] = (0.38, 0.565) +"""船坞满弹窗「解装」按钮 (底栏「解装|强化|扩充」三钮最左)。 + +弹窗模板 365x145 居中于 960x540 时, 左钮中心 ≈ (0.374, 0.567); +沿用 classic 实测坐标。点击后游戏直达建造页解体标签。""" + def destroy_ships( ctx: GameContext, @@ -38,8 +49,6 @@ def destroy_ships( remove_equipment: 是否在解装前卸下装备。默认 ``True``。 """ - from autowsgr.ui.build_page import BuildPage, BuildTab - _log.info('[OPS] 开始解装') goto_page(ctx, PageName.BUILD) @@ -51,7 +60,7 @@ def destroy_ships( _log.info('[OPS] 解装完成') -def destroy_ships_auto(ctx: GameContext) -> bool: +def destroy_ships_auto(ctx: GameContext, *, from_dialog: bool = False) -> bool: """按 ``ctx.config`` 的解装设置自动解装。 供 normal_fight / event_fight / decisive 船坞满时调用, 统一读取配置。 @@ -65,6 +74,15 @@ def destroy_ships_auto(ctx: GameContext) -> bool: ``remove_equipment`` 取自 ``remove_equipment_mode``。 + Parameters + ---------- + from_dialog: + ``True`` 时要求当前停在船坞满弹窗 (战斗准备页点出征后弹出): + 先点弹窗「解装」按钮直达建造页 (不绕主菜单/侧边栏导航), + 其后复用 :func:`destroy_ships` — 结束在主页面 (该入口的返回 + 无视 UI 栈, 解装页点返回直达主页, 不回战斗准备页)。 + ``False`` (默认) 走全局导航: 任意页面 → 建造页 (解体), 结束回主页面。 + Returns ------- bool @@ -86,9 +104,33 @@ def destroy_ships_auto(ctx: GameContext) -> bool: _log.warning('[OPS] 白名单包含全部舰种, 无可解装对象, 跳过') return False + if from_dialog: + _enter_destroy_page_from_dialog(ctx) destroy_ships( ctx, ship_types=ship_types, remove_equipment=cfg.remove_equipment_mode, ) return True + + +def _enter_destroy_page_from_dialog(ctx: GameContext) -> None: + """点船坞满弹窗「解装」按钮, 直达建造页解体标签。 + + 点击后游戏无视 UI 栈直达建造页 (不经过主菜单/侧边栏), 后续解装 + 复用 :func:`destroy_ships` — 其开头的 ``goto_page(BUILD)`` 幂等直达, + 结尾 ``goto_page(MAIN)`` 即解装页返回的落点 (无视 UI 栈回主页)。 + + Raises + ------ + NavigationError + 点弹窗按钮后未到达建造页。 + """ + _log.info('[OPS] 船坞满弹窗 → 直达解装') + click_and_wait_for_page( + ctx.ctrl, + click_coord=CLICK_DOCK_DIALOG_DESTROY, + checker=BuildPage.is_current_page, + source='船坞满弹窗', + target=PageName.BUILD, + ) diff --git a/autowsgr/ops/event_fight.py b/autowsgr/ops/event_fight.py index fd90025b..44e41ffd 100644 --- a/autowsgr/ops/event_fight.py +++ b/autowsgr/ops/event_fight.py @@ -30,14 +30,16 @@ validate_fleet_selection_arguments, ) from autowsgr.infra.logger import get_logger +from autowsgr.ops.navigate import goto_bath_from_event_sortie from autowsgr.ops.normal_fight import NormalFightRunner if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from autowsgr.context import GameContext + _log = get_logger('ops') @@ -79,6 +81,7 @@ def __init__( map_code: str | None = None, # noqa: ARG002 - 已废弃, 仅为兼容旧签名保留 entrance: Literal['alpha', 'beta'] | None = None, event_name: str | None = None, + repair_status_callback: Callable[[bool], None] | None = None, ) -> None: # entrance override: 覆盖 plan.entrance (UI 层 a/b ↔ α/β) if entrance is not None: @@ -93,8 +96,13 @@ def __init__( fleet_id=fleet_id, fleet=fleet, fleet_rules=fleet_rules, + repair_status_callback=repair_status_callback, ) + def _goto_bath_for_repair(self) -> None: + """从活动出征准备页返回活动地图,再进入澡堂。""" + goto_bath_from_event_sortie(self._ctx) + # ═══════════════════════════════════════════════════════════════════════════════ # 便捷函数 @@ -113,6 +121,7 @@ def run_event_fight( fleet: Sequence[str] | None = None, fleet_rules: Sequence[FleetSlotRule] | None = None, fleet_selection: ResolvedFleetSelection | None = None, + repair_status_callback: Callable[[bool], None] | None = None, ) -> list[CombatResult]: """执行活动战的便捷函数 (兼容入口, 委托 :class:`NormalFightRunner`)。 @@ -156,6 +165,7 @@ def run_event_fight( resolved_selection, map_code=map_code, entrance=entrance, + repair_status_callback=repair_status_callback, ) return runner.run_for_times(times, gap=gap) diff --git a/autowsgr/ops/exercise.py b/autowsgr/ops/exercise.py index 828b4a98..49eadecc 100644 --- a/autowsgr/ops/exercise.py +++ b/autowsgr/ops/exercise.py @@ -14,6 +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.ops.startup import recover_to_main_or_restart from autowsgr.types import ConditionFlag, Formation, PageName, ShipDamageState from autowsgr.ui import BattlePreparationPage, MapPage, MapPanel @@ -55,6 +56,9 @@ def run(self) -> list[CombatResult]: self._results = [] _log.info('[OPS] 开始演习流程') + # 0. 确保游戏处于可识别页面 (页面异常时先恢复/重启) + recover_to_main_or_restart(self._ctx, self._ctx.config.account.game_app) + # 1. 导航到演习面板 self._enter_exercise_page() rivals_status = MapPage(self._ctx).get_exercise_rival_status() @@ -142,6 +146,9 @@ class ExerciseOnceRunner(ExerciseRunner): def run(self) -> CombatResult: # type: ignore[override] """挑战下一个可用对手; 无对手返回 ``SKIP_FIGHT``。""" + # 确保游戏处于可识别页面 (页面异常时先恢复/重启) + recover_to_main_or_restart(self._ctx, self._ctx.config.account.game_app) + self._enter_exercise_page() rivals_status = MapPage(self._ctx).get_exercise_rival_status() rivals = rivals_status.rivals diff --git a/autowsgr/ops/navigate.py b/autowsgr/ops/navigate.py index 90656667..cb17ff28 100644 --- a/autowsgr/ops/navigate.py +++ b/autowsgr/ops/navigate.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger +from autowsgr.types import PageName from autowsgr.ui.navigation import find_path from autowsgr.ui.page import get_current_page from autowsgr.ui.utils import NavigationError @@ -35,10 +36,21 @@ # ═══════════════════════════════════════════════════════════════════════════════ -def identify_current_page(ctx: GameContext) -> str | None: +def identify_current_page( + ctx: GameContext, + candidates: set[str] | None = None, +) -> str | None: """截图并识别当前页面。 - 尝试多次截图以应对动画或加载中的情况。 + 尝试多次截图以应对动画或加载中的情况。候选集识别重试耗尽后 + 追加一轮全量识别兜底 —— 栈漂移可能把真实页面排除在候选之外。 + + Parameters + ---------- + candidates: + 候选页面名集合。``None`` 时评估全部注册页;导航过程中由 + :func:`_goto_page` 从 :attr:`GameContext.ui_stack` 计算并传入, + 以收缩搜索空间。 Returns ------- @@ -48,7 +60,7 @@ def identify_current_page(ctx: GameContext) -> str | None: ctrl = ctx.ctrl for attempt in range(MAX_IDENTIFY_ATTEMPTS): screen = ctrl.screenshot() - page = get_current_page(screen) + page = get_current_page(screen, candidates=candidates) if page is not None: return page _log.debug( @@ -56,9 +68,25 @@ def identify_current_page(ctx: GameContext) -> str | None: attempt + 1, ) time.sleep(IDENTIFY_INTERVAL) + + if candidates is not None: + # 候选耗尽:降级全量识别一轮(候选集可能因栈漂移漏掉真实页面) + screen = ctrl.screenshot() + page = get_current_page(screen) + if page is not None: + _log.warning('[OPS] 候选识别失败, 全量兜底命中: {}', page) + return page return None +def _sync_ctx_page(ctx: GameContext, name: str) -> None: + """把识别结果同步到 ``ctx.current_page`` (deprecated 字段, server 上报用)。""" + try: + ctx.current_page = PageName(name) + except ValueError: + ctx.current_page = None + + # ═══════════════════════════════════════════════════════════════════════════════ # 导航函数 # ═══════════════════════════════════════════════════════════════════════════════ @@ -68,9 +96,9 @@ def _goto_page(ctx: GameContext, target: str) -> None: """从当前页面导航到目标页面。 采用逐步重规划策略 (Step-by-Step Re-planning): - 1. 识别当前页面 + 1. 识别当前页面, 并用 ``ctx.ui_stack.observe`` 对账 2. BFS 查找路径 - 3. 执行路径的第一步 + 3. 声明意图 (``ui_stack.push``) 后执行路径的第一步 4. 循环回到 1,直到到达目标 这允许处理不确定的导航动作 (如: build -> sidebar | main)。 @@ -81,15 +109,22 @@ def _goto_page(ctx: GameContext, target: str) -> None: 无法识别当前页面、找不到路径或步数超限。 """ max_steps = 20 + stack = ctx.ui_stack + # 候选集:首轮全表扫描(真不知在哪);每步由栈给出 + # {current} + neighbors(current) + {parent} + {target} + neighbors(target), + # 相比纯邻域机制多纳入 parent, 覆盖回退时序与无入边叶子页 (CHOOSE_SHIP 等)。 + candidates: set[str] | None = None for step in range(max_steps): - # 1. 识别 - current = identify_current_page(ctx) + # 1. 识别 + 对账 + current = identify_current_page(ctx, candidates=candidates) if current is None: raise NavigationError( f'无法识别当前页面,导航中止 (目标: {target})', screen=ctx.ctrl.screenshot(), ) + stack.observe(current) + _sync_ctx_page(ctx, current) # 2. 检查 if current == target: @@ -108,7 +143,8 @@ def _goto_page(ctx: GameContext, target: str) -> None: _log.info('[OPS] 已在目标页面: {}', target) return - # 4. 执行一步 + # 4. 执行一步:先声明意图(下一轮识别的 observe 会纠正), + # 动作失败未到达时, 识别到 parent 会被 pop 回收 edge = path[0] _log.debug( '[OPS] 步骤 {} (总限 {}): {} → {} ({})', @@ -118,7 +154,9 @@ def _goto_page(ctx: GameContext, target: str) -> None: edge.target, edge.description, ) + stack.push(edge.target) edge.action(ctx) + candidates = stack.candidates(target) or None raise NavigationError( f'导航步数超限 ({max_steps}),目标: {target}', @@ -127,11 +165,62 @@ def _goto_page(ctx: GameContext, target: str) -> None: def goto_page(ctx: GameContext, target: str) -> None: - """导航到目标页面,失败时自动重试一次。""" + """导航到目标页面,失败时自动重试一次。 + + 首次失败时先用 ``ui_stack.resync`` 全量识别重建栈 (漂移后的栈 + 会把真实页面排除在候选外, 直接重试大概率再失败), 再重试导航。 + """ try: _goto_page(ctx, target) except NavigationError as e: _log.error('[OPS] 导航失败: {}', e) + resynced = ctx.ui_stack.resync(ctx.ctrl.screenshot()) + _log.info('[OPS] 栈重建 (resync): {}, 执行一次重试', resynced or '识别失败') current_page = identify_current_page(ctx) _log.info('[OPS] 当前页面: {}, 执行一次重试', current_page) _goto_page(ctx, target) + + +def goto_bath_from_normal_sortie(ctx: GameContext) -> None: + """从普通出征准备页返回普通地图后进入澡堂。""" + from autowsgr.ui.battle.preparation import BattlePreparationPage + + BattlePreparationPage(ctx).go_back() + goto_page(ctx, PageName.BATH) + + +def goto_bath_from_event_sortie(ctx: GameContext) -> None: + """从活动出征准备页返回活动地图后进入澡堂。""" + from autowsgr.ui.battle.constants import CLICK_BACK + from autowsgr.ui.event.event_page import BaseEventPage + from autowsgr.ui.utils import click_and_wait_for_page + + click_and_wait_for_page( + ctx.ctrl, + click_coord=CLICK_BACK, + checker=BaseEventPage.is_current_page, + source=PageName.BATTLE_PREP, + target=PageName.EVENT_MAP, + ) + goto_page(ctx, PageName.BATH) + + +def goto_bath_from_decisive_sortie(ctx: GameContext) -> None: + """从决战出征准备页暂离保存后进入澡堂。""" + from autowsgr.infra import DecisiveConfig + from autowsgr.ui.decisive import DecisiveMapController + from autowsgr.ui.decisive.battle_page import DecisiveBattlePage + from autowsgr.ui.utils import wait_for_page + + config = getattr(ctx.config, 'decisive_battle', None) or DecisiveConfig() + map_controller = DecisiveMapController(ctx, config) + map_controller.go_to_map_page() + map_controller.open_retreat_dialog() + map_controller.confirm_leave() + wait_for_page( + ctx.ctrl, + DecisiveBattlePage.is_current_page, + source='决战暂离', + target=PageName.DECISIVE_BATTLE, + ) + goto_page(ctx, PageName.BATH) diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index 00ebd109..a68a651d 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -21,14 +21,15 @@ ) from autowsgr.infra import ActionFailedError from autowsgr.infra.logger import get_logger -from autowsgr.ops.navigate import goto_page +from autowsgr.ops.navigate import goto_bath_from_normal_sortie, goto_page +from autowsgr.ops.repair import repair_manual_targets_in_bath from autowsgr.types import ConditionFlag, PageName, RepairMode, ShipDamageState from autowsgr.ui import BaseEventPage, BattlePreparationPage, MapPage, MapPanel, RepairStrategy from autowsgr.ui.utils import NavigationError if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from pathlib import Path from autowsgr.context import GameContext @@ -56,6 +57,7 @@ def __init__( fleet_id: int | None = None, fleet: Sequence[str] | None = None, fleet_rules: Sequence[FleetSlotRule] | None = None, + repair_status_callback: Callable[[bool], None] | None = None, ) -> None: validate_fleet_selection_arguments( fleet_selection, @@ -73,10 +75,10 @@ def __init__( slot_rules=fleet_rules, ) self._fleet_id = self._fleet_selection.fleet_id + self._repair_status_callback = repair_status_callback # 从 config 读取拆船配置 self._dock_full_destroy = ctx.config.dock_full_destroy - self._destroy_ship_types = ctx.config.destroy_ship_types or None # chapter 为 E/H → 活动地图入口; 否则常规地图。仅靠 plan 决定导航, # event 与 normal 共用本 runner (融合), 复用 normal_fight 触发器。 @@ -103,7 +105,8 @@ def __init__( self._entrance = None self._map_code = '' - # 首次执行检查难度/节点, 后续重复出征跳过 (仅 event 分支使用) + # 仅上一轮 OPERATION_SUCCESS (成功完成战斗, 战后回港必落关卡浮层态) + # 才跳过难度/节点检查直接出击; 中途打断/失败一律恢复完整检查 (仅 event 分支使用) self._skip_check = False self._results: list[CombatResult] = [] @@ -132,37 +135,43 @@ def run(self) -> CombatResult: self._plan.name, self._fleet_selection.source, ) + try: + # 1. 进入战斗地图 + self._enter_fight() + + # 2. 出征准备 + ship_stats = self._prepare_for_battle() + + # 同步战前信息到上下文 + self._ctx.sync_before_combat( + self._fleet_id, + self._fleet_ships, + loot_count=self._loot_count, + ship_acquired_count=self._ship_acquired_count, + ) - # 1. 进入战斗地图 - self._enter_fight() - - # 2. 出征准备 - ship_stats = self._prepare_for_battle() - - # 同步战前信息到上下文 - self._ctx.sync_before_combat( - self._fleet_id, - self._fleet_ships, - loot_count=self._loot_count, - ship_acquired_count=self._ship_acquired_count, - ) - - # 3. 执行战斗 - result = self._do_combat(ship_stats) + # 3. 执行战斗 + result = self._do_combat(ship_stats) - # 赋值出征面板识别到的今日获取数量和舰队信息 - result.loot_count = self._loot_count - result.ship_acquired_count = self._ship_acquired_count - result.fleet = self._fleet_ships + # 赋值出征面板识别到的今日获取数量和舰队信息 + result.loot_count = self._loot_count + result.ship_acquired_count = self._ship_acquired_count + result.fleet = self._fleet_ships - # 同步战后信息到上下文 - self._ctx.sync_after_combat(self._fleet_id, result) + # 同步战后信息到上下文 + self._ctx.sync_after_combat(self._fleet_id, result) - # 4. 处理结果 - self._handle_result(result) + # 4. 处理结果 + self._handle_result(result) + except Exception: + # 中途打断 (导航/编队/出征异常): 战后浮层态前提不可知, + # 下一轮恢复完整检查 (选难度/节点/入口) + self._skip_check = False + raise - # 后续重复出征跳过难度/节点检查 (event 分支使用) - self._skip_check = True + # 仅成功完成一场战斗才允许下一轮跳过检查 (战后回港必落关卡浮层态); + # 其余 (DOCK_FULL 解装 / SL / 次数用尽 / 失败) 浮层态前提破坏, 重新检查 + self._skip_check = result.flag == ConditionFlag.OPERATION_SUCCESS return result def run_for_times( @@ -256,6 +265,8 @@ def run_for_times_condition( target_result_index = result_list.index(result.upper()) start_time = time.time() self._results = [] + # 按评级判定是否计入次数 → 战果页需完整采集 (grade/MVP, 慢速通过) + self._plan.collect_result_info = True while times > 0: _log.info('[OPS] 条件战斗,剩余次数:{}', times) @@ -315,8 +326,12 @@ def _enter_normal(self) -> None: # 在出征面板读取今日已获取数量 map_page.ensure_panel(MapPanel.SORTIE) time.sleep(0.25) + # 战利品 OCR 与 YAML 联动: 仅在开启 stop_max_loot (战利品检查) 时识别, + # 无战利品活动时跳过该区域避免无效 OCR 报警 + da = self._ctx.config.daily_automation + read_loot = bool(da and da.stop_max_loot) try: - counts = map_page.get_loot_and_ship_count() + counts = map_page.get_loot_and_ship_count(read_loot=read_loot) self._loot_count = counts.loot self._ship_acquired_count = counts.ship except RuntimeError: @@ -392,9 +407,35 @@ def _prepare_for_battle(self) -> list[ShipDamageState]: min_mode = repair_modes.value if min_mode <= RepairMode.moderate_damage.value: - page.apply_repair(RepairStrategy.MODERATE) + repair_strategy = RepairStrategy.MODERATE elif min_mode <= RepairMode.severe_damage.value: - page.apply_repair(RepairStrategy.SEVERE) + repair_strategy = RepairStrategy.SEVERE + else: + repair_strategy = RepairStrategy.NEVER + + fleet_names = resolved_ship_names or self._fleet_selection.primary_names + if self._plan.repair_method == 'bath': + self._repair_in_bath(page, repair_strategy, fleet_names) + elif self._plan.repair_method == 'quick': + # 任务级配置优先于旧的全局 repair_manually。 + page.apply_repair(repair_strategy, repair_manually=False) + elif getattr(self._ctx.config, 'repair_manually', False): + # 兼容未声明 repair_method 的旧 YAML/客户端。 + def manual_repair_action(positions: list[int]) -> None: + targets = [ + fleet_names[position] + for position in positions + if 0 <= position < len(fleet_names) and fleet_names[position] + ] + self._goto_bath_for_repair() + repair_manual_targets_in_bath(self._ctx, targets) + + page.apply_repair( + repair_strategy, + manual_repair_action=manual_repair_action, + ) + else: + page.apply_repair(repair_strategy) # 检测战前舰队信息 (血量 + 等级) fleet_info = page.detect_fleet_info() @@ -413,6 +454,74 @@ def _prepare_for_battle(self) -> list[ShipDamageState]: return ship_stats + def _goto_bath_for_repair(self) -> None: + """从普通出征准备页返回地图,再进入澡堂。""" + goto_bath_from_normal_sortie(self._ctx) + + def _repair_in_bath( + self, + page: BattlePreparationPage, + strategy: RepairStrategy, + fleet_names: Sequence[str], + ) -> None: + """由后端派修并等待目标舰船恢复,避免 GUI 参与维修编排。""" + positions = page.check_repair(strategy) + if not positions: + return + + targets = [ + fleet_names[position] + for position in positions + if 0 <= position < len(fleet_names) and fleet_names[position] + ] + if not targets: + raise ActionFailedError('无法解析澡堂维修目标') + + if self._repair_status_callback is not None: + self._repair_status_callback(True) + try: + self._goto_bath_for_repair() + repair_manual_targets_in_bath(self._ctx, targets) + + ships = [self._ctx.get_ship(name) for name in targets] + failed = [ + ship.name + for ship in ships + if ship.damage_state != ShipDamageState.NORMAL + and not ship.is_repairing + ] + if failed: + raise ActionFailedError(f'澡堂维修失败: {", ".join(failed)}') + + deadline = max( + (ship.repair_end_time for ship in ships), + default=time.time(), + ) + 30.0 + while any(ship.is_repairing for ship in ships): + stop_event = getattr(self._ctx, 'stop_event', None) + if stop_event is not None and stop_event.is_set(): + raise ActionFailedError('任务已停止,终止澡堂维修等待') + if time.time() >= deadline: + raise ActionFailedError('澡堂维修等待超时') + time.sleep(1.0) + + failed = [ + ship.name + for ship in ships + if ship.damage_state != ShipDamageState.NORMAL + ] + if failed: + raise ActionFailedError(f'澡堂维修后仍有受损舰船: {", ".join(failed)}') + + # 派修流程结束后已返回首页,继续战斗前重新进入原出征准备页。 + self._enter_fight() + time.sleep(1.0) + page.select_fleet(self._fleet_id) + time.sleep(0.5) + finally: + if self._repair_status_callback is not None: + self._repair_status_callback(False) + # ── 战斗 ── def _do_combat(self, ship_stats: list[ShipDamageState]) -> CombatResult: @@ -437,17 +546,34 @@ def _handle_result(self, result: CombatResult) -> None: _log.info('[OPS] 常规战结果: {}', result.flag.value) def _handle_dock_full(self, result: CombatResult) -> None: - """船坞已满: 按配置自动解装并重试,或保持 DOCK_FULL 标志。""" + """船坞已满: 按配置自动解装,并保持 DOCK_FULL 标志。 + + 解装走弹窗直达路线: 点弹窗「解装」按钮直达解体标签 (不绕主 + 菜单/侧边栏导航 — 旧全局导航在 event 场景死循环, 2026-08-16 + 实机), 其后复用 destroy_ships, 结束在主页面, 下轮 run 重新 + 导航进图出击。 + + 解装成功**不翻 flag**: 本轮引擎未开打 (node_count=0), 翻成功 + 标志会让触发器把未打的轮次计入次数。改置 ``dock_full_destroyed``, + 由触发器/调度器识别"解装完毕、可重试"与"无法解装、须停止"。 + """ if self._dock_full_destroy: from autowsgr.ops.destroy import destroy_ships_auto - _log.warning('[OPS] 船坞已满,执行自动解装') - # 点击弹窗确认按钮 (legacy 坐标) - self._ctrl.click(0.38, 0.565) - if destroy_ships_auto(self._ctx): - # 解装成功 (调用方可根据需要重试出征) - result.flag = ConditionFlag.OPERATION_SUCCESS - # 否则无可解装对象 (白名单覆盖全部舰种), 保持 DOCK_FULL + _log.warning('[OPS] 船坞已满,执行自动解装 (弹窗直达)') + try: + destroyed = destroy_ships_auto(self._ctx, from_dialog=True) + except NavigationError as e: + _log.error('[OPS] 弹窗直达解装失败: {}, 回退主页面', e) + try: + goto_page(self._ctx, PageName.MAIN) + except NavigationError as back_err: + _log.error('[OPS] 返回主页面失败: {}', back_err) + return # 解装未执行, 保持 DOCK_FULL 且未置 destroyed, 由上层停止 + if destroyed: + # 解装成功 (结束在主页面), 下轮 run 重新导航进图出击 + result.dock_full_destroyed = True + # destroyed=False: 白名单覆盖全部舰种, 无可解装对象, 保持 DOCK_FULL return _log.warning('[OPS] 船坞已满, 未开启自动解装') @@ -500,6 +626,7 @@ def run_normal_fight( fleet: Sequence[str] | None = None, fleet_rules: Sequence[FleetSlotRule] | None = None, fleet_selection: ResolvedFleetSelection | None = None, + repair_status_callback: Callable[[bool], None] | None = None, ) -> list[CombatResult]: """执行常规战的便捷函数。""" validate_fleet_selection_arguments( @@ -518,6 +645,7 @@ def run_normal_fight( ctx, plan, resolved_selection, + repair_status_callback=repair_status_callback, ) return runner.run_for_times(times, gap=gap) diff --git a/autowsgr/ops/repair.py b/autowsgr/ops/repair.py index 8180ba76..46e4f761 100644 --- a/autowsgr/ops/repair.py +++ b/autowsgr/ops/repair.py @@ -15,11 +15,14 @@ from autowsgr.infra.logger import get_logger from autowsgr.ops.navigate import goto_page -from autowsgr.types import PageName +from autowsgr.types import PageName, ShipDamageState from autowsgr.ui.bath_page import BathPage +from autowsgr.ui.utils import NavigationError if TYPE_CHECKING: + from collections.abc import Sequence + from autowsgr.context import GameContext _log = get_logger('ops') @@ -29,6 +32,50 @@ # ═══════════════════════════════════════════════════════════════════════════════ +def repair_manual_targets_in_bath(ctx: GameContext, ship_names: Sequence[str]) -> None: + """在澡堂按目标舰船名派修,结束后返回首页。""" + page = BathPage(ctx) + targets = [name for name in ship_names if name] + + try: + if not targets: + _log.warning('[OPS] 手动维修: 未能解析需要修理的舰船') + return + + for ship_name in targets: + try: + page.go_to_choose_repair() + except NavigationError as exc: + _log.error('[OPS] 手动维修: 无法打开选择修理列表: {}', exc) + break + + try: + repair_seconds = page.repair_ship(ship_name) + except NavigationError: + _log.warning('[OPS] 手动维修: 舰船 {} 不在修理列表中', ship_name) + continue + + if repair_seconds < 0: + _log.warning('[OPS] 手动维修: 澡堂槽位已满,无法修理 {}', ship_name) + break + + ship = ctx.get_ship(ship_name) + ship.set_repair(repair_seconds) + ctx.update_ship_damage(ship_name, ShipDamageState.NORMAL) + ctx.bathroom.slot_count = ctx.config.bathroom_count + ctx.bathroom.occupy(repair_seconds) + _log.info( + '[OPS] 手动维修: 舰船 {} 正在澡堂修理 ({}s)', + ship_name, + repair_seconds, + ) + finally: + try: + goto_page(ctx, PageName.MAIN) + except Exception as exc: + _log.warning('[OPS] 手动维修后返回首页失败: {}', exc) + + def repair_in_bath(ctx: GameContext) -> None: """使用浴室修理修理时间最长的舰船。 @@ -76,6 +123,7 @@ def repair_ship_by_name(ctx: GameContext, ship_name: str) -> int: if repair_secs >= 0: ship = ctx.get_ship(ship_name) ship.set_repair(repair_secs) + ctx.update_ship_damage(ship_name, ShipDamageState.NORMAL) _log.info('[OPS] 浴室修理操作完成: {} ({}s)', ship_name, repair_secs) else: _log.warning('[OPS] 浴场已满, 无法修理 {}', ship_name) @@ -128,9 +176,12 @@ def repair_one_available( # 浴场满 (secs==-2)。空闲槽数 = slot_count, 故循环上限即槽位数, 不会死循环。 while bath.is_available(): page.go_to_choose_repair() - secs = page.repair_longest(blacklist=blocked) + ship_name, secs = page.repair_longest(blacklist=blocked) if secs > 0: + ship = ctx.get_ship(ship_name) + ship.set_repair(secs) + ctx.update_ship_damage(ship_name, ShipDamageState.NORMAL) bath.occupy(secs) repaired += 1 _log.info('[OPS] 浴室修理派单成功 ({}s, 本轮已派 {} 艘)', secs, repaired) diff --git a/autowsgr/ops/startup.py b/autowsgr/ops/startup.py index 9db6f468..8ee468f7 100644 --- a/autowsgr/ops/startup.py +++ b/autowsgr/ops/startup.py @@ -55,12 +55,6 @@ _STARTUP_POLL_INTERVAL: float = 1.0 """加载等待轮询间隔 (秒)。""" -_OVERLAY_DISMISS_TIMEOUT: float = 10.0 -"""等待浮层出现并消除的超时 (秒)。""" - -_OVERLAY_DISMISS_DELAY: float = 1.0 -"""消除浮层后的等待时间 (秒)。""" - _OVERLAY_DISMISS_MAX: int = 5 """每日浮层消除的最大尝试次数 (新闻 → 签到 → 确认 → 二次确认 → 兜底)。""" @@ -127,7 +121,8 @@ def wait_for_game_ui( # TODO: 游戏更新处理 """等待游戏进入任意可识别的游戏页面或启动画面。 - 通过反复截图,直到出现主页面或启动画面任意一种状态。 + 通过反复截图,直到出现以下任一状态即视为 UI 就绪: + 启动画面、登录浮层、任意已注册页面 (如主页面)。 Parameters ---------- @@ -143,6 +138,9 @@ def wait_for_game_ui( bool 超时前成功检测到返回 ``True``,超时返回 ``False``。 """ + # 延迟导入避免循环依赖 (ui 层反向引用 ops 的模块) + from autowsgr.ui.main_page.overlays import detect_overlay + from autowsgr.ui.page import get_current_page _log.info('[Startup] 等待游戏 UI 就绪 (超时 {:.0f}s)…', timeout) deadline = time.monotonic() + timeout @@ -155,6 +153,17 @@ def wait_for_game_ui( _log.info('[Startup] 检测到启动画面') return True + # 出现登录后浮层(依然算 UI 就绪) + if detect_overlay(screen) is not None: + _log.info('[Startup] 检测到登录浮层,游戏已加载') + return True + + # 已进入任意可识别页面 (如重启后直接恢复到主页面) + page = get_current_page(screen) + if page is not None: + _log.info('[Startup] 检测到已进入页面: {}', page) + return True + _log.debug('[Startup] 游戏尚未就绪,等待 {:.1f}s…', interval) time.sleep(interval) diff --git a/autowsgr/scheduler/daily_plan.py b/autowsgr/scheduler/daily_plan.py index bba87c4d..db723d78 100644 --- a/autowsgr/scheduler/daily_plan.py +++ b/autowsgr/scheduler/daily_plan.py @@ -254,6 +254,7 @@ def _register_normal_fight( name=task.name, fleet_id=fleet_id, target=task.times, # None = 无限 (空闲填充) + conditions=plan.conditions, # 镜像只读: 触发器按条件计数 ), ) diff --git a/autowsgr/scheduler/launcher.py b/autowsgr/scheduler/launcher.py index bf17a358..7f933091 100644 --- a/autowsgr/scheduler/launcher.py +++ b/autowsgr/scheduler/launcher.py @@ -136,16 +136,25 @@ def ctrl(self) -> AndroidController: # ── OCR ── def create_ocr(self) -> OCREngine: - """根据配置创建通用 OCR 引擎 (EasyOCR)。""" + """根据配置创建通用 OCR 引擎。 + + ``enhanced_ship_ocr`` 开启时使用 FastOCR (PP-OCRv6-small), + 否则使用 EasyOCR。淘汰 EasyOCR 过渡期: 开关控制全局 OCR 引擎。 + """ cfg = self.config - _log.info('[Launcher] 创建通用 OCR 引擎: EasyOCR') gpu = cfg.ocr.gpu gpu_override = os.getenv('AUTOWSGR_OCR_GPU_MODE', '').lower() if gpu_override == 'cuda': gpu = True elif gpu_override == 'cpu': gpu = False - self._ocr = EasyOCREngine.create(gpu=gpu, mirror=cfg.ocr.mirror) + + if getattr(cfg.ocr, 'enhanced_ship_ocr', False): + _log.info('[Launcher] 创建通用 OCR 引擎: FastOCR (PP-OCRv6-small)') + self._ocr = OCREngine.create(engine='fastocr') + else: + _log.info('[Launcher] 创建通用 OCR 引擎: EasyOCR (enhanced_ship_ocr 未开启)') + self._ocr = EasyOCREngine.create(gpu=gpu, mirror=cfg.ocr.mirror) # 同步船池感知匹配置信度到 ocr 模块 from autowsgr.vision.ocr import set_ship_name_match_confidence from autowsgr.vision.ocr_rules import ( @@ -168,17 +177,17 @@ def create_ocr(self) -> OCREngine: return self._ocr def create_ship_ocr(self) -> OCREngine | None: - """根据配置创建增强船只识别 OCR 引擎 (FastOCR / PP-OCRv6-small)。 + """创建增强船只识别 OCR 引擎。 - 仅在 ``cfg.ocr.enhanced_ship_ocr`` 开启时创建; - 默认关闭,返回 ``None``,船只识别节点继续使用默认 EasyOCR。 + ``enhanced_ship_ocr`` 开启时, 默认 OCR (:meth:`create_ocr`) 已是 + FastOCR, 无需单独创建 ship_ocr → 返回 ``None``。 + 未开启时返回 ``None``, 船只识别使用默认 EasyOCR。 """ - cfg = self.config - if not cfg.ocr.enhanced_ship_ocr: - _log.info('[Launcher] 增强船只识别 OCR 未开启,船只识别节点继续使用 EasyOCR') - return None - _log.info('[Launcher] 创建增强船只识别 OCR 引擎: FastOCR (PP-OCRv6-small)') - return OCREngine.create(engine='fastocr', gpu=False) + if getattr(self.config.ocr, 'enhanced_ship_ocr', False): + _log.info('[Launcher] 默认 OCR 已是 FastOCR, 无需单独 ship_ocr') + else: + _log.info('[Launcher] 增强船只识别 OCR 未开启, 船只识别使用默认 EasyOCR') + return None # ── 构造 GameContext ── @@ -203,9 +212,12 @@ def build_context(self) -> GameContext: ship_ocr=self.create_ship_ocr(), ) ship_engine = ( - 'FastOCR (PP-OCRv6-small)' if ctx.ship_ocr is not None else '未启用 (沿用 EasyOCR)' + 'FastOCR (PP-OCRv6-small)' if ctx.ship_ocr is not None else '未启用 (沿用默认引擎)' + ) + main_engine = ( + 'FastOCR' if getattr(self.config.ocr, 'enhanced_ship_ocr', False) else 'EasyOCR' ) - _log.info('[Launcher] OCR 加载完成: 通用引擎=EasyOCR, 船只识别引擎={}', ship_engine) + _log.info('[Launcher] OCR 加载完成: 通用引擎={}, 船只识别引擎={}', main_engine, ship_engine) _log.info('[Launcher] GameContext 已构建') return ctx @@ -236,12 +248,18 @@ def launch(self, ensure_game: bool = True) -> GameContext: 完全就绪的游戏上下文。 """ self.load_config() - self.connect() - ctx = self.build_context() - if ensure_game: - self.ensure_ready(ctx) - _log.info('[Launcher] 启动完成,游戏已就绪') - return ctx + try: + self.connect() + ctx = self.build_context() + if ensure_game: + self.ensure_ready(ctx) + except Exception: + if self._ctrl is not None: + self._ctrl.disconnect() + raise + else: + _log.info('[Launcher] 启动完成,游戏已就绪') + return ctx # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/autowsgr/scheduler/scheduler.py b/autowsgr/scheduler/scheduler.py index e1af5bb3..d00816cb 100644 --- a/autowsgr/scheduler/scheduler.py +++ b/autowsgr/scheduler/scheduler.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable from autowsgr.combat import CombatResult +from autowsgr.infra import ManualRepairRequiredError from autowsgr.infra.logger import get_logger from autowsgr.types import ConditionFlag @@ -234,7 +235,11 @@ def _run_task(self, task: FightTask) -> None: self._ctx.active_fight_tasks += 1 try: - for j in range(task.times): + # 按「真实开打的轮次」计数: 解装完毕的船坞满轮 (DOCK_FULL + + # dock_full_destroyed, 本轮未开打) 不占次数, 立即重试; 否则打满 + # task.times 轮为止 + rounds = 0 + while rounds < task.times: if self._ctx.stop_event.is_set(): _log.info('[Scheduler] {} 检测到停止信号, 中断', task.name) break @@ -242,7 +247,7 @@ def _run_task(self, task: FightTask) -> None: _log.info( '[Scheduler] {} 第 {}/{} 次', task.name, - j + 1, + rounds + 1, task.times, ) @@ -251,6 +256,15 @@ def _run_task(self, task: FightTask) -> None: try: results = runner.run() + except ManualRepairRequiredError as exc: + _log.error( + '[Scheduler] {} 第 {} 次需要手动维修,任务终止: {}', + task.name, + rounds + 1, + exc, + ) + task.results.append(CombatResult(flag=ConditionFlag.ACTION_FAILED)) + break except Exception as exc: # 子任务异常: 结束本子任务, 不崩溃主循环。ACTION_FAILED 不属 # 于任何触发器的成功/耗尽标志, 故 on_done 不会计入战斗次数、 @@ -258,17 +272,29 @@ def _run_task(self, task: FightTask) -> None: _log.opt(exception=True).error( '[Scheduler] {} 第 {} 次异常, 结束本子任务: {}', task.name, - j + 1, + rounds + 1, exc, ) results = [CombatResult(flag=ConditionFlag.ACTION_FAILED)] if not results: - _log.error('[Scheduler] {} 第 {} 次未执行任何战斗', task.name, j + 1) + _log.error( + '[Scheduler] {} 第 {} 次未执行任何战斗', + task.name, + rounds + 1, + ) results = [CombatResult(flag=ConditionFlag.ACTION_FAILED)] task.results.extend(results) - task.completed += 1 + + # 本轮全部为「解装完毕的船坞满」→ 未开打, 不占轮次, + # on_done 后立即重试 (下轮 run 重新导航进图) + dock_resolved = results and all( + r.flag == ConditionFlag.DOCK_FULL and r.dock_full_destroyed for r in results + ) + if not dock_resolved: + rounds += 1 + task.completed = rounds # 通知触发器更新状态 (auto_daily 触发器调度用) for result in results: @@ -283,17 +309,21 @@ def _run_task(self, task: FightTask) -> None: ) _log.info( - '[Scheduler] {} [{}/{}] → {}', + '[Scheduler] {} [{}/{}] → {}{}', task.name, task.completed, task.times, result.flag.value if result.flag else 'N/A', + ' (解装完毕, 重试)' if dock_resolved else '', ) - # 船坞满则停止当前任务 - if any(result.flag == ConditionFlag.DOCK_FULL for result in results): + # 船坞满且未解装 → 停止当前任务 (解装完毕的轮不进此分支, + # 循环自然进入下一轮重试) + if any( + r.flag == ConditionFlag.DOCK_FULL and not r.dock_full_destroyed for r in results + ): _log.warning( - '[Scheduler] {} 船坞已满, 跳过剩余 {} 次', + '[Scheduler] {} 船坞已满且无法解装, 跳过剩余 {} 次', task.name, task.times - task.completed, ) diff --git a/autowsgr/scheduler/triggers.py b/autowsgr/scheduler/triggers.py index 378a0875..4b076534 100644 --- a/autowsgr/scheduler/triggers.py +++ b/autowsgr/scheduler/triggers.py @@ -26,6 +26,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar +from autowsgr.combat import GradeCondition, grade_condition_met from autowsgr.infra.logger import get_logger from autowsgr.scheduler.scheduler import FightTask from autowsgr.types import ConditionFlag @@ -261,6 +262,11 @@ class NormalFightPlan: ``stop_max_ship`` / ``stop_max_loot`` / ``quick_repair_limit`` 约束)。 dev 的 ``DailyAutomationConfig.normal_fight_tasks`` 只给 plan 名、不给次数, 所以默认 ``None``, 多个无限 plan 会轮询执行。 + conditions: + 战果达成条件列表 (镜像自 :class:`CombatPlan.conditions`, 从 + ``node_args`` 各节点的 ``grade`` 派生)。非空时只有满足全部 + 条件的场次才计数; 配置条件的 plan 自动走慢速结算采集 + (grade/MVP), 触发器侧谓词才有材料可判。 completed: 已成功完成次数 (运行时, 仅日志/有限 plan 的停止判断用)。 """ @@ -269,6 +275,7 @@ class NormalFightPlan: name: str fleet_id: int target: int | None = None + conditions: tuple[GradeCondition, ...] = () completed: int = 0 @@ -372,8 +379,13 @@ 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: + # 仅成功打完一场才计数 (对齐 classic: SUCCESS/SL 才算); + # 配置 conditions 的 plan 还须战果全部达标 — 不达标的场次 (如 SL + # 重开、评级不足) 不计入, 触发器下轮继续产出直到达标次数打满。 + counted = result.flag in _DONE_FLAGS and all( + grade_condition_met(cond, result) for cond in self._current.conditions + ) + if counted: self._current.completed += 1 target = self._current.target progress = ( @@ -387,6 +399,13 @@ def _on_done(self, result: CombatResult) -> None: self._current.name, progress, ) + elif result.flag in _DONE_FLAGS and self._current.conditions: + _log.info( + '[Trigger] {} {} 本场未达战果条件 ({}), 不计数', + self.name, + self._current.name, + '/'.join(f'{c.node}>={c.grade}' for c in self._current.conditions), + ) def reset(self) -> None: for plan in self._plans: diff --git a/autowsgr/server/main.py b/autowsgr/server/main.py index 9d4c1463..25b8b3da 100644 --- a/autowsgr/server/main.py +++ b/autowsgr/server/main.py @@ -24,6 +24,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from loguru import logger as _loguru_logger from autowsgr.infra.logger import get_logger from autowsgr.server.task_manager import task_manager @@ -42,6 +43,7 @@ # 序列化系统上下文的发布、任务准入和回收。 lifecycle_lock = asyncio.Lock() +_stats_sink_id: int | None = None def get_context() -> Any: @@ -51,6 +53,41 @@ def get_context() -> Any: return _ctx +def register_stats_log_sink(loop: asyncio.AbstractEventLoop) -> None: + """Register the GUI stats sink after logging has been configured.""" + global _stats_sink_id + + if _stats_sink_id is not None: + try: + _loguru_logger.remove(_stats_sink_id) + except ValueError: + # setup_logger() removes every Loguru handler, including this stale sink. + pass + + _stats_sink_id = _loguru_logger.add( + ws_manager.build_log_sink(loop), + level='INFO', + filter=lambda r: True, # 过滤由 build_log_sink 内部按白名单完成 + backtrace=False, + diagnose=False, + ) + + +def remove_stats_log_sink() -> None: + """Remove the currently registered GUI stats sink.""" + global _stats_sink_id + + if _stats_sink_id is None: + return + + try: + _loguru_logger.remove(_stats_sink_id) + except ValueError: + # A later setup_logger() call may already have removed this sink. + pass + _stats_sink_id = None + + # ═══════════════════════════════════════════════════════════════════════════════ # 生命周期管理 # ═══════════════════════════════════════════════════════════════════════════════ @@ -59,17 +96,23 @@ def get_context() -> Any: @asynccontextmanager async def lifespan(app: FastAPI): # noqa: ARG001 """应用生命周期管理。""" - # 启动时: 设置事件循环引用 + # 启动时: 设置事件循环引用 + 注册统计专用 WebSocket loguru sink loop = asyncio.get_running_loop() task_manager.set_loop(loop) - _log.info('[Server] HTTP Server 已启动') + register_stats_log_sink(loop) + _log.info('[Server] HTTP Server 已启动, GUI 统计日志 sink 已注册') - yield + try: + yield + finally: + try: + if _ctx is not None: + from autowsgr.server.routes.system import system_stop - # 关闭时: 清理资源 - if _ctx is not None: - _log.info('[Server] 断开模拟器连接') - _log.info('[Server] HTTP Server 已关闭') + await system_stop() + finally: + remove_stats_log_sink() + _log.info('[Server] HTTP Server 已关闭') # ═══════════════════════════════════════════════════════════════════════════════ @@ -112,10 +155,8 @@ async def lifespan(app: FastAPI): # noqa: ARG001 # ═══════════════════════════════════════════════════════════════════════════════ -@app.websocket('/ws/logs') -async def ws_logs(websocket: WebSocket): - """实时日志流。""" - await ws_manager.connect(websocket) +async def _serve_websocket(websocket: WebSocket, stream: str) -> None: + await ws_manager.connect(websocket, stream) try: while True: data = await websocket.receive_text() @@ -126,24 +167,19 @@ async def ws_logs(websocket: WebSocket): except json.JSONDecodeError: pass except WebSocketDisconnect: - await ws_manager.disconnect(websocket) + await ws_manager.disconnect(websocket, stream) + + +@app.websocket('/ws/logs') +async def ws_logs(websocket: WebSocket): + """实时日志流。""" + await _serve_websocket(websocket, 'logs') @app.websocket('/ws/task') async def ws_task(websocket: WebSocket): """任务状态更新流。""" - await ws_manager.connect(websocket) - try: - while True: - data = await websocket.receive_text() - try: - msg = json.loads(data) - if msg.get('type') == 'ping': - await websocket.send_text(json.dumps({'type': 'pong'})) - except json.JSONDecodeError: - pass - except WebSocketDisconnect: - await ws_manager.disconnect(websocket) + await _serve_websocket(websocket, 'task') # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/autowsgr/server/routes/__init__.py b/autowsgr/server/routes/__init__.py index ca2e4702..6eb32c12 100644 --- a/autowsgr/server/routes/__init__.py +++ b/autowsgr/server/routes/__init__.py @@ -7,3 +7,15 @@ - ops: 操作端点 (/api/expedition/check, /api/build/*, /api/reward/*, /api/cook, /api/repair/*, /api/destroy) - health: 健康检查 (/api/health) """ + +from collections.abc import Callable +from typing import Any + +from fastapi import HTTPException + + +def require_context(getter: Callable[[], Any]) -> Any: + try: + return getter() + except RuntimeError as error: + raise HTTPException(status_code=503, detail=str(error)) from error diff --git a/autowsgr/server/routes/game.py b/autowsgr/server/routes/game.py index 00f719d5..b2f9214d 100644 --- a/autowsgr/server/routes/game.py +++ b/autowsgr/server/routes/game.py @@ -4,7 +4,7 @@ import asyncio -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from autowsgr.infra.logger import get_logger from autowsgr.server.device_lease import exclusive_device_operation @@ -17,6 +17,7 @@ ) from ..main import get_context +from . import require_context _log = get_logger('server') @@ -31,10 +32,7 @@ async def game_acquisition() -> ApiResponse: 仅在空闲时可用 (需要控制画面导航到出征面板)。 """ - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops.navigate import goto_page from autowsgr.ui.map.page import MapPage @@ -65,10 +63,7 @@ async def game_context_info() -> ApiResponse: 包含资源、舰队、远征、建造等完整游戏状态数据。 不需要截图或画面操作,直接读取内存中的状态。 """ - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) return ApiResponse( success=True, @@ -88,10 +83,7 @@ async def game_context_info() -> ApiResponse: @router.get('/api/expedition/status', response_model=ApiResponse) async def expedition_status() -> ApiResponse: """查询远征槽位状态(4 个槽位的章节、节点、剩余时间等)。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) return ApiResponse( success=True, @@ -102,10 +94,7 @@ async def expedition_status() -> ApiResponse: @router.get('/api/build/status', response_model=ApiResponse) async def build_status() -> ApiResponse: """查询建造队列状态。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) return ApiResponse( success=True, diff --git a/autowsgr/server/routes/ops.py b/autowsgr/server/routes/ops.py index 531f5708..ca418c14 100644 --- a/autowsgr/server/routes/ops.py +++ b/autowsgr/server/routes/ops.py @@ -5,7 +5,7 @@ import asyncio from typing import Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from pydantic import BaseModel from autowsgr.infra.logger import get_logger @@ -13,6 +13,7 @@ from autowsgr.server.schemas import ApiResponse from ..main import get_context +from . import require_context _log = get_logger('server') @@ -27,10 +28,7 @@ @exclusive_device_operation('api:expedition-check') async def expedition_check() -> ApiResponse: """检查并收取已完成的远征。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops.expedition import collect_expedition @@ -60,10 +58,7 @@ async def expedition_auto_check(request: ExpeditionAutoCheckRequest) -> ApiRespo 顺带领取任务奖励并根据调用方配置决定是否执行浴室维修。 """ - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops.expedition import collect_expedition from autowsgr.ops.repair import repair_in_bath @@ -123,10 +118,7 @@ class BuildStartRequest(BaseModel): @exclusive_device_operation('api:build-collect') async def build_collect() -> ApiResponse: """收取已完成的建造。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import collect_built_ships @@ -142,10 +134,7 @@ async def build_collect() -> ApiResponse: @exclusive_device_operation('api:build-start') async def build_start(request: BuildStartRequest) -> ApiResponse: """开始建造。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import BuildRecipe, build_ship @@ -177,10 +166,7 @@ async def build_start(request: BuildStartRequest) -> ApiResponse: @exclusive_device_operation('api:reward-collect') async def reward_collect() -> ApiResponse: """收取任务奖励。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import collect_rewards @@ -206,10 +192,7 @@ class CookRequest(BaseModel): @exclusive_device_operation('api:cook') async def cook_action(request: CookRequest) -> ApiResponse: """食堂烹饪。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import cook @@ -230,10 +213,7 @@ async def cook_action(request: CookRequest) -> ApiResponse: @exclusive_device_operation('api:repair-bath') async def repair_bath() -> ApiResponse: """浴室修理。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import repair_in_bath @@ -259,10 +239,7 @@ async def repair_ship(request: RepairShipRequest) -> ApiResponse: 前端泡澡修理系统调用此端点,将指定舰船送入浴室修理。 后端会导航到浴室页面,打开选择修理 overlay,查找并点击指定舰船。 """ - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops.repair import repair_ship_by_name @@ -297,10 +274,7 @@ class DestroyRequest(BaseModel): @exclusive_device_operation('api:destroy') async def destroy_action(request: DestroyRequest) -> ApiResponse: """解装/解体舰船。""" - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) from autowsgr.ops import destroy_ships from autowsgr.types import ShipType diff --git a/autowsgr/server/routes/system.py b/autowsgr/server/routes/system.py index 4e9d45d7..a4d1f2a9 100644 --- a/autowsgr/server/routes/system.py +++ b/autowsgr/server/routes/system.py @@ -45,6 +45,7 @@ async def system_start(request: SystemStartRequest) -> ApiResponse: config_path = request.config_path or 'usersettings.yaml' _log.info('[System] 正在启动, 配置: {}', config_path) _main._ctx = await asyncio.to_thread(launch, config_path=config_path) + _main.register_stats_log_sink(asyncio.get_running_loop()) _log.info('[System] 启动成功') return ApiResponse(success=True, message='系统启动成功') @@ -80,6 +81,12 @@ async def system_stop() -> ApiResponse: except DeviceOperationBusyError as error: raise HTTPException(status_code=409, detail=str(error)) from error try: + ctx = _main._ctx + await asyncio.to_thread(ctx.ctrl.disconnect) + except Exception as error: + _log.error('[System] 断开模拟器连接失败: {}', error) + return ApiResponse(success=False, error=str(error)) + else: _main._ctx = None _log.info('[System] 系统已停止') return ApiResponse(success=True, message='系统已停止') diff --git a/autowsgr/server/routes/task.py b/autowsgr/server/routes/task.py index 55c5aa4d..8b661950 100644 --- a/autowsgr/server/routes/task.py +++ b/autowsgr/server/routes/task.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException from pydantic import Discriminator +from autowsgr.infra import ManualRepairRequiredError from autowsgr.infra.logger import get_logger from autowsgr.server.device_lease import DeviceOperationBusyError from autowsgr.server.schemas import ( @@ -19,6 +20,7 @@ TaskStatusResponse, ) from autowsgr.server.serializers import ( + apply_combat_plan_overrides, build_combat_plan, build_fleet_selection, convert_combat_result, @@ -26,12 +28,30 @@ from autowsgr.server.task_manager import TaskOutcome, task_manager from ..main import get_context, lifecycle_lock +from . import require_context _log = get_logger('server') router = APIRouter(prefix='/api/task', tags=['task']) +_DECISIVE_MAX_ATTEMPTS = 3 + + +def _recover_decisive_after_error(ctx: Any, attempt: int) -> None: + """SL back to a clean game entry before the next decisive attempt.""" + from autowsgr.ops import ensure_game_ready, restart_game + + app = ctx.config.account.game_app + package = app.package_name if hasattr(app, 'package_name') else app + _log.warning( + '[Task] decisive recovery {}/{}: restart game and restore entry state', + attempt, + _DECISIVE_MAX_ATTEMPTS, + ) + restart_game(ctx.ctrl, package) + ensure_game_ready(ctx, app) + TaskRequestUnion = Annotated[ NormalFightRequest | EventFightRequest | CampaignRequest | ExerciseRequest | DecisiveRequest, @@ -39,6 +59,23 @@ ] +def _start_task( + task_type: str, + total_rounds: int, + executor: Any, +) -> ApiResponse: + task_id = task_manager.start_task( + task_type=task_type, + total_rounds=total_rounds, + executor=executor, + ) + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) + + @router.post('/start', response_model=ApiResponse) async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[arg-type] """启动任务 (异步执行,立即返回)。""" @@ -46,10 +83,7 @@ async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[ if task_manager.is_running: raise HTTPException(status_code=409, detail='已有任务正在运行') - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e + ctx = require_context(get_context) ctx.stop_event = task_manager.stop_event @@ -73,21 +107,17 @@ async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[ @router.post('/stop', response_model=ApiResponse) async def task_stop() -> ApiResponse: """停止当前任务。""" - if not task_manager.is_running: + if not task_manager.stop_task(): return ApiResponse(success=True, message='没有正在运行的任务') - success = task_manager.stop_task() - if success: - return ApiResponse( - success=True, - data={ - 'task_id': task_manager.current_task.task_id, - 'status': 'stopped', - }, - message='已请求停止任务', - ) - else: - return ApiResponse(success=False, error='停止失败') + return ApiResponse( + success=True, + data={ + 'task_id': task_manager.current_task.task_id, + 'status': 'stopped', + }, + message='已请求停止任务', + ) @router.get( @@ -116,12 +146,13 @@ def executor(_task_info: Any) -> TaskOutcome: if request.plan_id: plan = CombatPlan.from_yaml(request.plan_id) + apply_combat_plan_overrides(plan, request.plan) elif request.plan: plan = build_combat_plan(request.plan) else: raise ValueError('必须提供 plan 或 plan_id') - # API plan 覆盖 YAML 舰队;DTO 在 runner 启动前转换成领域模型。 + # API plan 覆盖 YAML 节点配置和舰队;DTO 在 runner 启动前转换成领域模型。 fleet_selection = build_fleet_selection(plan, request.plan) for i in range(request.times): @@ -137,26 +168,22 @@ def executor(_task_info: Any) -> TaskOutcome: plan, times=1, fleet_selection=fleet_selection, + repair_status_callback=task_manager.set_repairing, )[0] results.append(convert_combat_result(result, i + 1)) task_manager.add_result(results[-1]) + except ManualRepairRequiredError as e: + _log.error('[Task] 第 {} 轮需要手动维修,任务终止: {}', i + 1, e) + # TODO: 未来接入任务持久化挂起恢复,并通过换船 OCR 识别维修中舰船后降级到备选编队。 + results.append({'round': i + 1, 'success': False, 'error': str(e)}) + break except Exception as e: _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) results.append({'round': i + 1, 'success': False, 'error': str(e)}) return TaskOutcome.from_results(results) - task_id = task_manager.start_task( - task_type='normal_fight', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) + return _start_task('normal_fight', request.times, executor) async def _start_event_fight(ctx: Any, request: EventFightRequest) -> ApiResponse: @@ -169,6 +196,7 @@ def executor(_task_info: Any) -> TaskOutcome: if request.plan_id: plan = CombatPlan.from_yaml(request.plan_id) + apply_combat_plan_overrides(plan, request.plan) elif request.plan: plan = build_combat_plan(request.plan) else: @@ -194,6 +222,7 @@ def executor(_task_info: Any) -> TaskOutcome: plan, times=1, fleet_selection=fleet_selection, + repair_status_callback=task_manager.set_repairing, )[0] results.append(convert_combat_result(result, i + 1)) task_manager.add_result(results[-1]) @@ -203,17 +232,7 @@ def executor(_task_info: Any) -> TaskOutcome: return TaskOutcome.from_results(results) - task_id = task_manager.start_task( - task_type='event_fight', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) + return _start_task('event_fight', request.times, executor) async def _start_campaign(ctx: Any, request: CampaignRequest) -> ApiResponse: @@ -239,6 +258,7 @@ def executor(_task_info: Any) -> TaskOutcome: result = runner.run() for j, r in enumerate(result): converted = convert_combat_result(r, i * len(result) + j + 1) + converted['result'] = r.flag.value results.append(converted) task_manager.add_result(converted) except Exception as e: @@ -247,17 +267,7 @@ def executor(_task_info: Any) -> TaskOutcome: return TaskOutcome.from_results(results) - task_id = task_manager.start_task( - task_type='campaign', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) + return _start_task('campaign', request.times, executor) async def _start_exercise(ctx: Any, request: ExerciseRequest) -> ApiResponse: @@ -276,17 +286,7 @@ def executor(_task_info: Any) -> TaskOutcome: except Exception as e: return TaskOutcome.from_results([{'round': 1, 'success': False, 'error': str(e)}]) - task_id = task_manager.start_task( - task_type='exercise', - total_rounds=1, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) + return _start_task('exercise', 1, executor) async def _start_decisive(ctx: Any, request: DecisiveRequest) -> ApiResponse: @@ -316,7 +316,39 @@ def executor(_task_info: Any) -> TaskOutcome: task_manager.update_progress(current_round=i + 1, current_node='决战') _log.info('[Task] 决战第 {}/{} 轮', i + 1, request.decisive_rounds) - result = controller.run() + result = None + for attempt in range(1, _DECISIVE_MAX_ATTEMPTS + 1): + if task_manager.should_stop(): + break + _log.info( + '[Task] decisive round {} attempt {}/{}', + i + 1, + attempt, + _DECISIVE_MAX_ATTEMPTS, + ) + try: + result = controller.run(full_recovery_check=attempt > 1) + except Exception: + _log.exception('[Task] decisive attempt raised an exception') + from autowsgr.ops import DecisiveResult + + result = DecisiveResult.ERROR + if result.value != 'error': + break + _log.warning( + '[Task] decisive round {} failed, recovering before attempt {}/{}', + i + 1, + attempt, + _DECISIVE_MAX_ATTEMPTS, + ) + try: + _recover_decisive_after_error(ctx, attempt) + except Exception: + _log.exception('[Task] decisive error recovery failed') + if attempt == _DECISIVE_MAX_ATTEMPTS: + break + if result is None: + break is_error = result.value == 'error' converted = { 'round': i + 1, @@ -332,19 +364,12 @@ def executor(_task_info: Any) -> TaskOutcome: if result.value in {'leave', 'error'}: _log.warning('[Task] 决战第 {} 轮终止: {}', i + 1, result.value) break + except ManualRepairRequiredError as e: + task_error = str(e) + results.append({'round': len(results) + 1, 'success': False, 'error': str(e)}) except Exception as e: results.append({'round': len(results) + 1, 'success': False, 'error': str(e)}) return TaskOutcome.from_results(results, error=task_error) - task_id = task_manager.start_task( - task_type='decisive', - total_rounds=request.decisive_rounds, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) + return _start_task('decisive', request.decisive_rounds, executor) diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index 278ed44d..4a25b076 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -30,20 +30,12 @@ class TaskStatusEnum(StrEnum): IDLE = 'idle' RUNNING = 'running' + REPAIRING = 'repairing' COMPLETED = 'completed' FAILED = 'failed' STOPPED = 'stopped' -class LogLevel(StrEnum): - """日志级别。""" - - DEBUG = 'DEBUG' - INFO = 'INFO' - WARNING = 'WARNING' - ERROR = 'ERROR' - - type FormationAction = Annotated[int, Field(strict=True, ge=1, le=5)] type RuleSpec = tuple[str, Literal['retreat', 'detour'] | FormationAction] """HTTP rule item: condition expression plus retreat/detour/formation action.""" @@ -123,7 +115,7 @@ class FleetShipRuleRequest(BaseModel): max_level: int | None = Field(default=None, ge=1, description='等级上限(含)') relaxed: bool = Field( default=False, - description='宽松校验:舰名必须命中,等级/舰种尽力而为(识别失败或不匹配也放行)', + description='宽松校验:舰名必须命中;等级/舰种识别失败可放行,明确不匹配仍淘汰', ) @field_validator('name') @@ -220,6 +212,10 @@ class CombatPlanRequest(BaseModel): default_factory=lambda: [2, 2, 2, 2, 2, 2], description='修理策略 (6个位置)', ) + repair_method: Literal['quick', 'bath'] | None = Field( + default=None, + description='维修方式:quick=快速维修,bath=澡堂维修;未传时兼容全局 repair_manually', + ) fight_condition: int = Field(default=4, ge=1, le=5, description='战况选择') selected_nodes: list[str] = Field( default_factory=list, @@ -387,24 +383,6 @@ class TaskStatusResponse(BaseModel): error: str | None = Field(default=None, description='错误信息') -class SystemStatusResponse(BaseModel): - """系统状态响应。""" - - status: TaskStatusEnum = Field(description='系统状态') - emulator_connected: bool = Field(default=False, description='模拟器已连接') - game_running: bool = Field(default=False, description='游戏运行中') - current_task: str | None = Field(default=None, description='当前任务ID') - - -class LogMessage(BaseModel): - """日志消息。""" - - timestamp: str = Field(description='时间戳 ISO 8601') - level: LogLevel = Field(description='日志级别') - channel: str = Field(default='', description='日志通道') - message: str = Field(description='日志内容') - - class ApiResponse[ResponseDataT](BaseModel): """通用 API 响应。""" diff --git a/autowsgr/server/serializers.py b/autowsgr/server/serializers.py index 6488e50b..b3f78e8a 100644 --- a/autowsgr/server/serializers.py +++ b/autowsgr/server/serializers.py @@ -6,6 +6,7 @@ from __future__ import annotations +import copy from typing import TYPE_CHECKING, Any @@ -97,31 +98,29 @@ def convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: # noq events: list[dict[str, Any]] = [] if result.history: - for event in result.history.events: - if event.node and event.node not in nodes: - nodes.append(event.node) - fight_results = result.history.get_fight_results() - if isinstance(fight_results, dict): - for fr in fight_results.values(): - if fr.mvp and fr.mvp > 0 and mvp is None: - mvp = f'位置{fr.mvp}' - if fr.grade and grade is None: - grade = fr.grade - elif isinstance(fight_results, list): - for fr in fight_results: - if fr.mvp and fr.mvp > 0 and mvp is None: - mvp = f'位置{fr.mvp}' - if fr.grade and grade is None: - grade = fr.grade + fight_results_iter = ( + fight_results.values() + if isinstance(fight_results, dict) + else fight_results + ) + for fr in fight_results_iter: + if fr.mvp and fr.mvp > 0 and mvp is None: + mvp = f'位置{fr.mvp}' + if fr.grade and grade is None: + grade = fr.grade for event in result.history.events: + if event.node and event.node not in nodes: + nodes.append(event.node) ev: dict[str, Any] = { 'type': event.event_type.name, 'node': event.node, 'action': event.action, } - if event.result: + # 仅当 result 有有效值时写入:'' (空字符串) 和 None (未识别/默认值) + # 都视为无有效结果。注意: 不能用 `if event.result:`, 字符串'0'也能通过。 + if event.result and event.result.strip(): ev['result'] = event.result if event.enemies: ev['enemies'] = event.enemies @@ -134,6 +133,7 @@ def convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: # noq return { 'round': round_num, 'success': result.flag.value == 'success', + 'dock_full_destroyed': bool(getattr(result, 'dock_full_destroyed', False)), 'nodes': nodes, 'mvp': mvp, 'grade': grade, @@ -180,14 +180,103 @@ def _build_node_decision( fleet_id=request.fleet_id, fleet=request.fleet, repair_mode=[RepairMode(r) for r in request.repair_mode], + repair_method=request.repair_method, fight_condition=request.fight_condition, selected_nodes=request.selected_nodes, default_node=NodeDecision.from_dict(node_defaults), nodes=node_args, + node_overrides={ + node: node_request.model_dump(exclude_unset=True) + for node, node_request in request.node_args.items() + }, event_name=request.event_name, ) +def _overlay_node_decision_data(base: Any, data: dict[str, Any]) -> Any: + """把明确提供的节点字段覆盖到默认决策。""" + from autowsgr.combat import NodeDecision + + normalized_data = dict(data) + legacy_sl_key = 'sl_when_detour_fails' + canonical_sl_key = 'SL_when_detour_fails' + if legacy_sl_key in normalized_data: + normalized_data.setdefault(canonical_sl_key, normalized_data[legacy_sl_key]) + del normalized_data[legacy_sl_key] + + parsed = NodeDecision.from_dict( + {key: value for key, value in normalized_data.items() if value is not None}, + ) + result = copy.deepcopy(base) + attribute_names = {'enemy_formation_rules': 'formation_rules'} + for field_name, value in normalized_data.items(): + attribute_name = attribute_names.get(field_name, field_name) + setattr( + result, + attribute_name, + None if value is None else copy.deepcopy(getattr(parsed, attribute_name)), + ) + return result + + +def _overlay_node_decision(base: Any, request: Any) -> Any: + """把请求中明确提供的字段覆盖到默认决策。""" + return _overlay_node_decision_data( + base, + request.model_dump(exclude_unset=True), + ) + + +def apply_combat_plan_overrides( + plan: CombatPlan, + request: CombatPlanRequest | None, +) -> CombatPlan: + """把 API 中明确给出的节点配置应用到 YAML 计划。""" + if request is None: + return plan + + fields = request.model_fields_set + if 'repair_mode' in fields: + from autowsgr.types import RepairMode + + plan.repair_mode = [RepairMode(value) for value in request.repair_mode] + if 'repair_method' in fields: + plan.repair_method = request.repair_method + + if 'selected_nodes' in fields: + plan.selected_nodes = list(request.selected_nodes) + + if 'node_defaults' in fields: + from autowsgr.combat import NodeDecision + + plan.default_node = NodeDecision.from_dict( + request.node_defaults.model_dump(exclude_none=True), + ) + plan.nodes = { + node: _overlay_node_decision_data( + plan.default_node, + plan.node_overrides.get(node, {}), + ) + for node in plan.nodes + } + for node in plan.selected_nodes: + plan.nodes.setdefault(node, copy.deepcopy(plan.default_node)) + + if 'node_args' in fields: + plan.node_overrides = { + node: node_request.model_dump(exclude_unset=True) + for node, node_request in request.node_args.items() + } + plan.nodes = { + node: _overlay_node_decision(plan.default_node, node_request) + for node, node_request in request.node_args.items() + } + for node in plan.selected_nodes: + plan.nodes.setdefault(node, copy.deepcopy(plan.default_node)) + + return plan + + def build_fleet_selection( plan: CombatPlan, request_plan: CombatPlanRequest | None, diff --git a/autowsgr/server/task_manager.py b/autowsgr/server/task_manager.py index 91a8c1c3..5f963f75 100644 --- a/autowsgr/server/task_manager.py +++ b/autowsgr/server/task_manager.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Coroutine _log = get_logger('server.task') @@ -31,6 +31,7 @@ class TaskStatus(Enum): IDLE = 'idle' RUNNING = 'running' + REPAIRING = 'repairing' COMPLETED = 'completed' FAILED = 'failed' STOPPED = 'stopped' @@ -134,8 +135,11 @@ def current_task(self) -> TaskInfo | None: @property def is_running(self) -> bool: - """是否有任务正在运行。""" - return self._current_task is not None and self._current_task.status == TaskStatus.RUNNING + """是否有任务正在运行或等待澡堂维修。""" + return self._current_task is not None and self._current_task.status in ( + TaskStatus.RUNNING, + TaskStatus.REPAIRING, + ) @property def stop_event(self) -> threading.Event: @@ -146,6 +150,20 @@ def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: """设置事件循环引用,用于从线程中调用 async 函数。""" self._loop = loop + def _submit_to_loop(self, coroutine: Coroutine[Any, Any, Any]) -> None: + """Submit a notification unless application shutdown closed the loop.""" + loop = self._loop + if loop is None or loop.is_closed(): + coroutine.close() + return + + try: + asyncio.run_coroutine_threadsafe(coroutine, loop) + except RuntimeError: + coroutine.close() + if not loop.is_closed(): + raise + def start_task( self, task_type: str, @@ -211,34 +229,32 @@ def _run_in_thread( try: outcome = executor(task) - task.results = outcome.results - - # 检查是否被请求停止 - if task.stop_requested: - task.status = TaskStatus.STOPPED - elif outcome.success: - task.status = TaskStatus.COMPLETED - else: - task.status = TaskStatus.FAILED - task.error = outcome.error - - task.finished_at = datetime.now(UTC).isoformat() + with self._lock: + task.results = outcome.results + + # 检查是否被请求停止 + if task.stop_requested: + task.status = TaskStatus.STOPPED + elif outcome.success: + task.status = TaskStatus.COMPLETED + else: + task.status = TaskStatus.FAILED + task.error = outcome.error + + task.finished_at = datetime.now(UTC).isoformat() _log.info('[Task] 任务完成: {} ({})', task.task_id, task.status.value) except Exception as e: - task.status = TaskStatus.FAILED - task.error = str(e) - task.finished_at = datetime.now(UTC).isoformat() + with self._lock: + task.status = TaskStatus.FAILED + task.error = str(e) + task.finished_at = datetime.now(UTC).isoformat() _log.error('[Task] 任务失败: {} - {}', task.task_id, e) finally: self._device_lease.release(lease_token) # 通过事件循环发送 WebSocket 通知 - if self._loop: - asyncio.run_coroutine_threadsafe( - self._notify_completion(task), - self._loop, - ) + self._submit_to_loop(self._notify_completion(task)) async def _notify_completion(self, task: TaskInfo) -> None: """发送任务完成通知。""" @@ -280,6 +296,27 @@ def wait_for_completion(self, timeout: float | None = None) -> bool: thread.join(timeout=timeout) return not thread.is_alive() + def set_repairing(self, repairing: bool) -> None: + """更新任务的澡堂维修状态,并通过 WebSocket 通知客户端。""" + task = self._current_task + if task is None: + return + + with self._lock: + if task.status not in (TaskStatus.RUNNING, TaskStatus.REPAIRING): + return + task.status = TaskStatus.REPAIRING if repairing else TaskStatus.RUNNING + status = task.status.value + progress = task.progress + + self._submit_to_loop( + ws_manager.send_task_update( + task_id=task.task_id, + status=status, + progress=progress, + ) + ) + def update_progress( self, current_round: int | None = None, @@ -295,15 +332,13 @@ def update_progress( self._current_task.current_node = current_node # 通过事件循环发送 WebSocket 更新 - if self._loop: - asyncio.run_coroutine_threadsafe( - ws_manager.send_task_update( - task_id=self._current_task.task_id, - status='running', - progress=self._current_task.progress, - ), - self._loop, + self._submit_to_loop( + ws_manager.send_task_update( + task_id=self._current_task.task_id, + status=self._current_task.status.value, + progress=self._current_task.progress, ) + ) def add_result(self, result: dict[str, Any]) -> None: """添加一轮结果 (从执行线程调用)。""" @@ -335,7 +370,9 @@ def get_status(self) -> dict[str, Any]: return { 'task_id': task.task_id, 'status': task.status.value, - 'progress': task.progress if task.status == TaskStatus.RUNNING else None, + 'progress': task.progress + if task.status in (TaskStatus.RUNNING, TaskStatus.REPAIRING) + else None, 'result': result, 'error': task.error, } diff --git a/autowsgr/server/ws_manager.py b/autowsgr/server/ws_manager.py index ea1bed25..14f91145 100644 --- a/autowsgr/server/ws_manager.py +++ b/autowsgr/server/ws_manager.py @@ -4,17 +4,41 @@ import asyncio import json +import re from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from autowsgr.infra.logger import get_logger if TYPE_CHECKING: + from collections.abc import Callable + from fastapi import WebSocket _log = get_logger('server.ws') +Stream = Literal['logs', 'task'] + +# ═══════════════════════════════════════════════════════════════════════════════ +# GUI 统计所需日志白名单正则 +# ═══════════════════════════════════════════════════════════════════════════════ +# DailySortieStats.consume 从 WebSocket log 消息中解析统计数据(战斗/快修/泡澡/战利品/船数/掉落/远征) +# 只推送匹配以下正则的日志,避免 GUI 收到全量 INFO 噪音。 +_STATS_LOG_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r'\[Combat\]\s*战果:.*评价'), # 战斗评级 (battleCount/grades) + re.compile(r'\[Combat\]\s*获得舰船'), # 舰船掉落 (shipCount/shipDrops) + re.compile(r'\[UI\]\s*战利品数量:\s*\d'), # 战利品统计 (lootCount/lootLimit) + re.compile(r'\[UI\]\s*舰船数量:\s*\d'), # 船坞容量统计 (shipCount/shipLimit) + re.compile(r'\[UI\]\s*修理位置:'), # 快修使用 (quickRepairCount) + re.compile(r'\[OPS\]\s*浴室修理'), # 泡澡修理 (bathRepairCount) + re.compile(r'\[UI\]\s*远征收取:\s*\d'), # 远征完成 (expeditionCount) +) + + +def _is_stats_log(message: str) -> bool: + """判断日志内容是否为 GUI 统计所需。""" + return any(p.search(message) for p in _STATS_LOG_PATTERNS) class WebSocketManager: @@ -24,32 +48,32 @@ class WebSocketManager: """ def __init__(self) -> None: - self._connections: set[WebSocket] = set() + self._connections: dict[Stream, set[WebSocket]] = {'logs': set(), 'task': set()} self._lock = asyncio.Lock() - async def connect(self, websocket: WebSocket) -> None: + async def connect(self, websocket: WebSocket, stream: Stream) -> None: """接受新连接。""" await websocket.accept() async with self._lock: - self._connections.add(websocket) - _log.info('[WS] 新连接, 当前连接数: {}', len(self._connections)) + self._connections[stream].add(websocket) + _log.info('[WS] {} 新连接, 当前连接数: {}', stream, len(self._connections[stream])) - async def disconnect(self, websocket: WebSocket) -> None: + async def disconnect(self, websocket: WebSocket, stream: Stream) -> None: """断开连接。""" async with self._lock: - self._connections.discard(websocket) - _log.info('[WS] 断开连接, 当前连接数: {}', len(self._connections)) + self._connections[stream].discard(websocket) + _log.info('[WS] {} 断开连接, 当前连接数: {}', stream, len(self._connections[stream])) - async def broadcast(self, message: dict[str, Any]) -> None: + async def broadcast(self, stream: Stream, message: dict[str, Any]) -> None: """广播消息到所有连接。""" - if not self._connections: + if not self._connections[stream]: return data = json.dumps(message, ensure_ascii=False) dead_connections = [] async with self._lock: - for ws in list(self._connections): + for ws in list(self._connections[stream]): try: await ws.send_text(data) except Exception: @@ -57,7 +81,7 @@ async def broadcast(self, message: dict[str, Any]) -> None: # 清理断开的连接 for ws in dead_connections: - self._connections.discard(ws) + self._connections[stream].discard(ws) async def send_log( self, @@ -67,6 +91,7 @@ async def send_log( ) -> None: """发送日志消息。""" await self.broadcast( + 'logs', { 'type': 'log', 'timestamp': datetime.now(UTC).isoformat(), @@ -93,7 +118,7 @@ async def send_task_update( payload['progress'] = progress if result: payload['result'] = result - await self.broadcast(payload) + await self.broadcast('task', payload) async def send_task_completed( self, @@ -104,6 +129,7 @@ async def send_task_completed( ) -> None: """发送任务完成通知。""" await self.broadcast( + 'task', { 'type': 'task_completed', 'task_id': task_id, @@ -113,6 +139,29 @@ async def send_task_completed( } ) + # ══════════════════════════════════════════════════════════════════════ + # 统计日志 sink (供 GUI DailySortieStats 消费) + # ══════════════════════════════════════════════════════════════════════ + + def build_log_sink(self, loop: asyncio.AbstractEventLoop) -> Callable[[dict[str, Any]], None]: + """构建 loguru sink 回调,仅推送 GUI 统计需要的日志。 + + 只推送匹配 :data:`_STATS_LOG_PATTERNS` 白名单的消息; + 普通点击/导航/初始化等 INFO 日志被过滤掉,避免 GUI 端日志面板噪音。 + """ + def _sink(message: Any) -> None: # loguru.Message (avoid runtime import) + text = str(message.record.get('message', '')) + if not _is_stats_log(text): + return + raw_level = message.record.get('level', '') + level = raw_level.name if hasattr(raw_level, 'name') else str(raw_level) + ch = message.record.get('extra', {}).get('ch', '') or '' + + if not loop.is_closed(): + asyncio.run_coroutine_threadsafe(self.send_log(level, text, ch), loop) + + return _sink + # 全局单例 ws_manager = WebSocketManager() diff --git a/autowsgr/ui/README.md b/autowsgr/ui/README.md index 2d2424c1..14126ca5 100644 --- a/autowsgr/ui/README.md +++ b/autowsgr/ui/README.md @@ -276,14 +276,8 @@ class CanteenPage: ```python class ChooseShipPage: - def click_search_box(self) -> None: - """点击搜索框。""" - def input_ship_name(self, name: str) -> None: - """输入舰船名。""" - def dismiss_keyboard(self) -> None: - """关闭软键盘。""" - def click_first_result(self) -> None: - """点击搜索结果第一项。""" + def change_single_ship(self, selector: ShipSelector | None) -> str | None: + """按规则查找并选择舰船;selector 为 None 时移除当前舰船。""" def click_remove(self) -> None: """移除当前槽位舰船。""" ``` diff --git a/autowsgr/ui/__init__.py b/autowsgr/ui/__init__.py index 7c24b429..27ec7dd3 100644 --- a/autowsgr/ui/__init__.py +++ b/autowsgr/ui/__init__.py @@ -115,8 +115,15 @@ register_page(PageName.MAIN, MainPage.is_current_page) register_page(PageName.MAP, MapPage.is_current_page) -register_page(PageName.BATTLE_PREP, BattlePreparationPage.is_current_page) -register_page(PageName.SIDEBAR, SidebarPage.is_current_page) +# BATTLE_PREP 不注册: 出征准备页在游戏流转中是单向中转 (战斗结束跳过它直接回 +# 准备页之前的 UI —— 演习/战役/决战/活动皆然), 不属于 UI 导航域。它是战斗 +# 流程的锚点: start_fight / wait_leave_page 等直接引用 +# BattlePreparationPage.is_current_page 签名 (不经过注册中心)。 +# 侧边栏是左侧抽屉, 不遮挡主页面右侧识别元素 — 双命中时主页面分数更高 +# (0.988 vs ~0.86), 注册为覆盖型: 命中时优先判侧边栏。否则导航会在 +# "已到达侧边栏"与"当前是主页面"间震荡, 反复点切换按钮把侧边栏开了又关 +# (实机 2026-08-16: 船坞满自动解装的 MAIN→SIDEBAR 死循环 → NavError)。 +register_page(PageName.SIDEBAR, SidebarPage.is_current_page, overlay=True) register_page(PageName.MISSION, MissionPage.is_current_page) register_page(PageName.BACKYARD, BackyardPage.is_current_page) register_page(PageName.BATH, BathPage.is_current_page) diff --git a/autowsgr/ui/backyard_page.py b/autowsgr/ui/backyard_page.py index 0b4a397a..156fa655 100644 --- a/autowsgr/ui/backyard_page.py +++ b/autowsgr/ui/backyard_page.py @@ -16,12 +16,14 @@ import enum from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import PageName from autowsgr.ui.utils import click_and_wait_for_page from autowsgr.vision import ( + ImageChecker, MatchStrategy, - PixelChecker, + PageMatch, PixelRule, PixelSignature, ) @@ -103,18 +105,24 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为后院页面。 - 通过 5 个特征像素点 (背景及装饰) 全部匹配判定。 + 用后院页面独有特征模板匹配 (迁移自 classic ``backyard_page``, + 482x387 大区域模板, 区分度极高), 返回带置信度的 + :class:`PageMatch` 供候选集排序。旧像素签名 ``PAGE_SIGNATURE`` + 保留备查, 不再参与判定。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - result = PixelChecker.check_signature(screen, PAGE_SIGNATURE) - return result.matched + result = ImageChecker.find_template(screen, Templates.Page.BACKYARD, confidence=0.85) + name = PageName.BACKYARD.value + if result is None: + return PageMatch(name=name, matched=False, score=0.0) + return PageMatch(name=name, matched=True, score=result.confidence) # ── 导航 ────────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/bath_page/page.py b/autowsgr/ui/bath_page/page.py index 348287c8..0d3c3ca7 100644 --- a/autowsgr/ui/bath_page/page.py +++ b/autowsgr/ui/bath_page/page.py @@ -29,24 +29,24 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger +from autowsgr.types import PageName from autowsgr.ui.bath_page.signatures import ( BATH_FULL_TIMEOUT, - CHOOSE_REPAIR_OVERLAY_SIGNATURE, CLICK_BACK, CLICK_CHOOSE_REPAIR, CLICK_CLOSE_OVERLAY, CLICK_FIRST_REPAIR_SHIP, CLICK_REPAIR_ALL, CLOSE_OVERLAY_BUTTON_COLOR, - PAGE_SIGNATURE, REPAIR_ALL_BUTTON_COLOR, SWIPE_DELAY, SWIPE_DURATION, SWIPE_END, SWIPE_START, ) -from autowsgr.vision import Color, PixelChecker +from autowsgr.vision import Color, ImageChecker, PageMatch, PixelChecker if TYPE_CHECKING: @@ -139,21 +139,29 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为浴室页面 (含 overlay 状态)。 - 无论选择修理 overlay 是否打开,都识别为浴室页面。 + 基础检测用浴室页面独有特征模板匹配 (迁移自 classic ``bath_page``, + 127x41 局部特征); "选择修理" overlay 打开时基础特征可能被遮挡, + 回退到 overlay 模板补救。两种情况都识别为浴室页面, 返回带 + 置信度的 :class:`PageMatch`。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - # 先检查基础浴室签名 - if PixelChecker.check_signature(screen, PAGE_SIGNATURE).matched: - return True - # overlay 打开时基础签名可能被遮挡,单独检查 overlay 签名 - return PixelChecker.check_signature(screen, CHOOSE_REPAIR_OVERLAY_SIGNATURE).matched + name = PageName.BATH.value + # 基础: 模板匹配 + result = ImageChecker.find_template(screen, Templates.Bath.BATH, confidence=0.85) + if result is not None: + return PageMatch(name=name, matched=True, score=result.confidence) + # overlay 打开时基础特征被遮挡, 回退到 overlay 模板 + overlay = ImageChecker.find_template(screen, Templates.Bath.CHOOSE_REPAIR, confidence=0.85) + if overlay is not None: + return PageMatch(name=name, matched=True, score=overlay.confidence) + return PageMatch(name=name, matched=False, score=0.0) @staticmethod def has_choose_repair_overlay(screen: np.ndarray) -> bool: @@ -164,10 +172,7 @@ def has_choose_repair_overlay(screen: np.ndarray) -> bool: screen: 截图 (HxWx3, RGB)。 """ - return PixelChecker.check_signature( - screen, - CHOOSE_REPAIR_OVERLAY_SIGNATURE, - ).matched + return ImageChecker.template_exists(screen, Templates.Bath.CHOOSE_REPAIR, confidence=0.85) # ── Overlay 操作 ────────────────────────────────────────────────────── @@ -209,8 +214,7 @@ def close_choose_repair_overlay(self) -> None: wait_for_page( self._ctrl, lambda s: ( - PixelChecker.check_signature(s, PAGE_SIGNATURE).matched - and not BathPage.has_choose_repair_overlay(s) + BathPage.is_current_page(s).matched and not BathPage.has_choose_repair_overlay(s) ), source='选择修理 overlay', target='浴室', @@ -351,7 +355,7 @@ def repair_ship(self, ship_name: str) -> int: screen=self._ctrl.screenshot(), ) - def repair_longest(self, blacklist: set[str] | None = None) -> int: + def repair_longest(self, blacklist: set[str] | None = None) -> tuple[str, int]: """在选择修理 overlay 中修理修理时间最长的非黑名单舰船。 逐页扫描 (最多 10 页), 在第一个含非黑名单候选的页内选最长者点击。 @@ -364,9 +368,9 @@ def repair_longest(self, blacklist: set[str] | None = None) -> int: Returns ------- - int - 修理秒数 (``>0`` 成功); ``-1`` 无可修候选 (空或全被排除); - ``-2`` 浴场已满 (点击后 overlay 未关闭)。 + tuple[str, int] + 成功时返回 ``(舰船名, 修理秒数)``;无可修候选返回 ``('', -1)``; + 浴场已满返回 ``('', -2)``。 """ blocked = blacklist or set() target: RepairShipInfo | None = None @@ -388,7 +392,7 @@ def repair_longest(self, blacklist: set[str] | None = None) -> int: if target is None: _log.info('[UI] 选择修理: 无可修理舰船 (或均被黑名单排除)') - return -1 + return '', -1 _log.info( '[UI] 选择修理 → 修理最长: {} ({})', @@ -397,9 +401,9 @@ def repair_longest(self, blacklist: set[str] | None = None) -> int: ) self._ctrl.click(*target.position) if self._try_wait_overlay_close(): - return target.repair_seconds + return target.name, target.repair_seconds _log.warning('[UI] 浴场已满, 修理 {} 失败', target.name) - return -2 + return '', -2 def recognize_repair_ships(self) -> list[RepairShipInfo]: """识别选择修理 overlay 中当前可见的待修理舰船。 @@ -441,8 +445,7 @@ def _wait_overlay_auto_close(self) -> None: wait_for_page( self._ctrl, lambda s: ( - PixelChecker.check_signature(s, PAGE_SIGNATURE).matched - and not BathPage.has_choose_repair_overlay(s) + BathPage.is_current_page(s).matched and not BathPage.has_choose_repair_overlay(s) ), source='选择修理 overlay (自动关闭)', target='浴室', @@ -460,9 +463,9 @@ def _try_wait_overlay_close(self) -> bool: deadline = time.monotonic() + BATH_FULL_TIMEOUT while time.monotonic() < deadline: screen = self._ctrl.screenshot() - if PixelChecker.check_signature( - screen, PAGE_SIGNATURE - ).matched and not BathPage.has_choose_repair_overlay(screen): + if BathPage.is_current_page(screen).matched and not BathPage.has_choose_repair_overlay( + screen + ): return True time.sleep(0.5) return False diff --git a/autowsgr/ui/bath_page/recognition.py b/autowsgr/ui/bath_page/recognition.py index 3c20612c..4a50fa19 100644 --- a/autowsgr/ui/bath_page/recognition.py +++ b/autowsgr/ui/bath_page/recognition.py @@ -74,9 +74,6 @@ _NAME_CONF_MIN = 0.1 """名称 OCR 置信度下限。低于此值的识别结果不可信。""" -_TIME_Y_MIN = 200 -"""时间文本 y 坐标下限 (相对于卡片下半部分)。""" - _TIME_CROP_Y = 195 """时间区域裁剪起始 y 坐标 (相对于卡片下半部分)。""" diff --git a/autowsgr/ui/battle/base.py b/autowsgr/ui/battle/base.py index 35f2e56b..68b07e63 100644 --- a/autowsgr/ui/battle/base.py +++ b/autowsgr/ui/battle/base.py @@ -9,6 +9,7 @@ import enum from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import PageName from autowsgr.ui.battle.constants import ( @@ -24,9 +25,11 @@ PANEL_ACTIVE, STATE_TOLERANCE, ) -from autowsgr.ui.utils import click_and_wait_leave_page +from autowsgr.ui.utils import click_and_wait_for_page from autowsgr.vision import ( + ImageChecker, MatchStrategy, + PageMatch, PixelChecker, PixelRule, PixelSignature, @@ -137,10 +140,17 @@ def _preferred_ocr(self) -> OCREngine | None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: - """判断截图是否为出征准备页面。""" - result = PixelChecker.check_signature(screen, PAGE_SIGNATURE) - return result.matched + def is_current_page(screen: np.ndarray) -> PageMatch: + """判断截图是否为出征准备页面。 + + 用出征准备页面独有特征模板匹配 (迁移自 classic ``fight_prepare_page``, + 183x48 局部特征), 返回带置信度的 :class:`PageMatch` 供候选集排序。 + """ + result = ImageChecker.find_template(screen, Templates.Page.BATTLE_PREP, confidence=0.85) + name = PageName.BATTLE_PREP.value + if result is None: + return PageMatch(name=name, matched=False, score=0.0) + return PageMatch(name=name, matched=True, score=result.confidence) # ── 状态查询 — 舰队 / 面板 ──────────────────────────────────────────── @@ -171,11 +181,17 @@ def is_auto_supply_enabled(screen: np.ndarray) -> bool: # ── 动作 — 回退 / 出征 ─────────────────────────────────────────────── def go_back(self) -> None: - """点击回退按钮 (◁),返回地图页面。""" + """点击回退按钮 (◁),返回地图页面。 + + 用**到达验证** (:func:`click_and_wait_for_page`): 点击后确认真的到了 + 地图页面。此前用 ``wait_leave_page`` 语义 (checker 为 False 即"已离开") + 存在假成功——点击后画面仍在出征准备页时, ``MapPage.is_current_page`` + 同样为 False, 第一帧就误判离开, 从未验证到达。 + """ from autowsgr.ui.map.page import MapPage _log.debug('[UI] 出征准备 → 回退') - click_and_wait_leave_page( + click_and_wait_for_page( self._ctrl, click_coord=CLICK_BACK, checker=MapPage.is_current_page, diff --git a/autowsgr/ui/battle/fleet_change/_selection.py b/autowsgr/ui/battle/fleet_change/_selection.py index 8fdc446a..150896d3 100644 --- a/autowsgr/ui/battle/fleet_change/_selection.py +++ b/autowsgr/ui/battle/fleet_change/_selection.py @@ -6,13 +6,12 @@ from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import TYPE_CHECKING from autowsgr.combat.fleet import ShipSelector from autowsgr.infra.logger import get_logger from autowsgr.ui.battle.constants import CLICK_BACK -from autowsgr.vision.ocr_rules import get_user_ship_name_aliases from ._planning import FleetPlanningMixin @@ -40,15 +39,6 @@ class _ShipSelection: class FleetSelectionMixin(FleetPlanningMixin): """提供船池页面的进入、退出、选择和移除操作。""" - def _search_options(self, option: ShipSelector) -> tuple[ShipSelector, ...]: - """按固定顺序生成自定义舰名和标准舰名搜索规则。""" - if not self._use_search or option.search_name is not None: - return (option,) - - aliases = get_user_ship_name_aliases(option.name) - search_names = aliases if option.name in aliases else (*aliases, option.name) - return tuple(replace(option, search_name=name) for name in search_names) - def _open_choose_page(self, slot: int) -> ChooseShipPage: """打开指定物理槽位的选船页面。""" from autowsgr.ui.choose_ship_page import ChooseShipPage @@ -86,16 +76,14 @@ def _try_select_option( if self._ctx.ocr is None: raise RuntimeError('智能换船需要 OCR 引擎') - for search_option in self._search_options(option): - choose_page = self._open_choose_page(slot) - selected = choose_page.change_single_ship( - search_option, - use_search=self._use_search, - ) - if selected is not None: - return _ShipSelection(name=selected, option=option) + choose_page = self._open_choose_page(slot) + selected = choose_page.change_single_ship( + option, + use_search=self._use_search, + ) + if selected is None: self._cancel_choose_page() - return _ShipSelection(name=None, option=option) + return _ShipSelection(name=selected, option=option) # 打开指定槽位的选船页面,完成单艘舰船的选择或移除。 def _change_single_ship( diff --git a/autowsgr/ui/battle/repair.py b/autowsgr/ui/battle/repair.py index 5fcbab2f..254d9957 100644 --- a/autowsgr/ui/battle/repair.py +++ b/autowsgr/ui/battle/repair.py @@ -6,14 +6,19 @@ from __future__ import annotations import time +from typing import TYPE_CHECKING -from autowsgr.infra import ActionFailedError +from autowsgr.infra import ActionFailedError, ManualRepairRequiredError from autowsgr.infra.logger import get_logger from autowsgr.types import ShipDamageState from autowsgr.ui.battle.base import BaseBattlePreparation, RepairStrategy from autowsgr.ui.battle.constants import BLOOD_BAR_PROBE +if TYPE_CHECKING: + from collections.abc import Callable + + _log = get_logger('ui.preparation') @@ -52,6 +57,8 @@ def check_repair( ---------- strategy: 修理策略,默认 ``RepairStrategy.SEVERE``。 + manual_repair_action: + 手动维修模式识别到受损舰船后、抛出异常前执行的导航动作。 Returns ------- @@ -79,7 +86,8 @@ def apply_repair( self, strategy: RepairStrategy | None = None, *, - repair_manually: bool = False, + repair_manually: bool | None = None, + manual_repair_action: Callable[[list[int]], None] | None = None, retry_count: int = 3, ) -> list[int]: """根据策略执行快速修理。 @@ -88,6 +96,8 @@ def apply_repair( ---------- strategy: 修理策略,默认 ``RepairStrategy.SEVERE``。 + repair_manually: + 显式传入时覆盖全局配置;``None`` 表示沿用全局 ``repair_manually``。 Returns ------- @@ -107,8 +117,20 @@ def apply_repair( if not positions: return [] # 需要手动修理,退出程序 - if self._ctx.config.repair_manually or repair_manually: - raise ActionFailedError('需要进行手动修理') + manual_repair_enabled = ( + self._ctx.config.repair_manually + if repair_manually is None + else repair_manually + ) + if manual_repair_enabled: + if manual_repair_action is not None: + try: + manual_repair_action(positions) + except ManualRepairRequiredError: + raise + except Exception as exc: + raise ManualRepairRequiredError('手动维修处理失败') from exc + raise ManualRepairRequiredError('需要进行手动修理') self.repair_slots(positions) repair_pos.extend(positions) # 修理完成再检查一遍 diff --git a/autowsgr/ui/build_page.py b/autowsgr/ui/build_page.py index d2ec5a0a..8791703a 100644 --- a/autowsgr/ui/build_page.py +++ b/autowsgr/ui/build_page.py @@ -32,13 +32,13 @@ from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import PageName, ShipType -from autowsgr.vision import ImageChecker +from autowsgr.vision import ImageChecker, PageMatch from .page import click_and_wait_for_page from .tabbed_page import ( TabbedPageType, + check_tabbed_page, get_active_tab_index, - identify_page_type, make_tab_checker, ) @@ -161,17 +161,17 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为建造页面组 (含全部 4 个标签)。 - 通过统一标签页检测层识别。 + 通过统一标签页检测层识别,返回带覆盖度分数的 PageMatch。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - return identify_page_type(screen) == TabbedPageType.BUILD + return check_tabbed_page(screen, TabbedPageType.BUILD) @staticmethod def get_active_tab(screen: np.ndarray) -> BuildTab | None: diff --git a/autowsgr/ui/canteen_page.py b/autowsgr/ui/canteen_page.py index 836c7ca3..e915e498 100644 --- a/autowsgr/ui/canteen_page.py +++ b/autowsgr/ui/canteen_page.py @@ -19,11 +19,12 @@ from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger +from autowsgr.types import PageName from autowsgr.ui.utils import click_and_wait_for_page from autowsgr.vision import ( ImageChecker, MatchStrategy, - PixelChecker, + PageMatch, PixelRule, PixelSignature, ) @@ -119,18 +120,23 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为食堂页面。 - 通过 5 个特征像素点全部匹配判定。 + 用食堂页面独有特征模板匹配 (迁移自 classic ``canteen_page``, 86x33 + 局部特征), 返回带置信度的 :class:`PageMatch` 供候选集排序。 + 旧像素签名 ``PAGE_SIGNATURE`` 保留备查, 不再参与判定。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - result = PixelChecker.check_signature(screen, PAGE_SIGNATURE) - return result.matched + result = ImageChecker.find_template(screen, Templates.Page.CANTEEN, confidence=0.85) + name = PageName.CANTEEN.value + if result is None: + return PageMatch(name=name, matched=False, score=0.0) + return PageMatch(name=name, matched=True, score=result.confidence) # ── 回退 ────────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index f20a237f..c49fd626 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -1,14 +1,6 @@ """选船页面 UI 控制器。 -已完成,需测试 - -使用方式:: - - from autowsgr.ui.choose_ship_page import ChooseShipPage - - page = ChooseShipPage(ctrl) - page.click_search_box() - page.click_first_result() +使用 :meth:`ChooseShipPage.change_single_ship` 根据舰名、等级和舰种查找并选择舰船。 """ from __future__ import annotations @@ -22,8 +14,10 @@ from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger +from autowsgr.types import PageName from autowsgr.vision import ( MatchStrategy, + PageMatch, PixelChecker, PixelRule, PixelSignature, @@ -64,9 +58,6 @@ CLICK_REMOVE_SHIP: tuple[float, float] = (83 / 960, 167 / 540) """「移除」按钮 — 将当前槽位舰船移除。""" -CLICK_FIRST_RESULT: tuple[float, float] = (183 / 960, 167 / 540) -"""搜索结果列表中的第一个结果。""" - #: 选船列表滚动参数 _SCROLL_FROM_Y: float = 0.55 _SCROLL_TO_Y: float = 0.30 @@ -164,12 +155,11 @@ def _detect_hit_ship_type( # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为选船页面。 - .. warning:: - 尚未实现像素签名采集,当前始终返回 False。 - 选船页面识别由 ops 层通过图像模板匹配完成。 + 返回带匹配比例的 :class:`PageMatch` 供候选集排序 + (``PageMatch.__bool__`` 保证旧式真值调用不变)。 Parameters ---------- @@ -177,7 +167,11 @@ def is_current_page(screen: np.ndarray) -> bool: 截图 (HxWx3, RGB)。 """ result = PixelChecker.check_signature(screen, PAGE_SIGNATURE) - return result.matched + return PageMatch( + name=PageName.CHOOSE_SHIP.value, + matched=result.matched, + score=result.ratio, + ) def _wait_leave_current_page(self, timeout: float = 5.0): wait_leave_page( @@ -222,11 +216,6 @@ def ensure_dismiss_keyboard(self) -> None: # 等待键盘关闭 time.sleep(0.2) - def click_first_result(self) -> None: - """点击搜索结果中的第一个舰船。""" - _log.debug('[UI] 选船 → 点击第一个结果') - self._ctrl.click(*CLICK_FIRST_RESULT) - def click_remove(self) -> None: """点击「移除」按钮,移除当前槽位的舰船。""" _log.debug('[UI] 选船 → 移除舰船') diff --git a/autowsgr/ui/decisive/battle_page.py b/autowsgr/ui/decisive/battle_page.py index 4c7c6f7b..ac3b4cc7 100644 --- a/autowsgr/ui/decisive/battle_page.py +++ b/autowsgr/ui/decisive/battle_page.py @@ -21,12 +21,14 @@ from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import DecisiveEntryStatus, PageName -from autowsgr.ui.utils import click_and_wait_for_page, confirm_operation +from autowsgr.ui.utils import NavigationError, click_and_wait_for_page, confirm_operation from autowsgr.vision import ( + ROI, Color, ImageChecker, MatchStrategy, OCREngine, + PageMatch, PixelChecker, PixelRule, PixelSignature, @@ -80,6 +82,17 @@ CLICK_RESET_CHAPTER: tuple[float, float] = (0.5, 0.925) """点击"重置关卡"按钮(总览页底部)。""" +RESET_BUTTON_ROI = ROI(0.64, 0.84, 0.73, 1.0).expand_pixels(1280, 720) +"""1280x720 总览页中「重置关卡」按钮的固定识别区域。""" + +RESET_ENTRY_ROI = ROI(0.35, 0.80, 0.70, 1.0).expand_pixels(1280, 720) +"""``refresh`` 状态下底部中央「重置关卡」入口的识别区域。""" + +ENTRY_STATUS_ROI = ROI(547 / 1280, 635 / 720, 814 / 1280, 705 / 720).expand_pixels( + 1280, 720 +) +"""1280x720 总览页底部中央入口状态按钮的固定识别区域。""" + CHAPTER_NUM_AREA: tuple[float, float, float, float] = (0.818, 0.810, 0.875, 0.867) """章节编号 OCR 裁切区域 (x1, y1, x2, y2)。""" @@ -120,13 +133,13 @@ 5: [(0.418, 0.378), (0.760, 0.477), (0.550, 0.750)], 6: [(0.606, 0.375), (0.532, 0.703), (0.862, 0.644)], } -"""每章 3 个小关的像素检测点 (相对坐标)。 +"""每章 3 个小关的节点存在检测点 (相对坐标)。 -若检测点颜色接近白色 (250, 244, 253) 表示该小关已通过。 +若检测点颜色接近白色 (250, 244, 253) 表示该小节节点已出现在总览页。 """ _STAGE_CHECK_COLOR: Color = Color.of(250, 244, 253) -"""小关已通过标记颜色 (近白色)。""" +"""小节节点存在标记颜色 (近白色)。""" _STAGE_CHECK_TOLERANCE: float = 30.0 """颜色匹配容差。""" @@ -163,39 +176,92 @@ def __init__( # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: - """判断截图是否为决战总览页。""" - return PixelChecker.check_signature(screen, PAGE_SIGNATURE).matched + def is_current_page(screen: np.ndarray) -> PageMatch: + """判断截图是否为决战总览页。 + + 用总览页独有的入口状态图标 (4 种: 无法出击 / 挑战中 / 已刷新 / 可重置, + 即 classic ``decisive_battle_image[3:7]``) 匹配, 命中任一即认定在总览页。 + 这些图标只在总览页出现 — 全截图交叉验证其他页置信度 < 0.7, 不误命中。 + 阈值 0.8 与 :meth:`detect_entry_status` 一致 (入口状态图标本身较小, + 置信度约 0.8+)。旧像素签名 ``PAGE_SIGNATURE`` (4 个深色背景点, 曾在 + 深色活动主题页误命中) 保留备查, 不再参与判定。 + """ + result = ImageChecker.find_any( + screen, + Templates.Decisive.entry_status_templates(), + roi=ENTRY_STATUS_ROI, + confidence=0.8, + ) + name = PageName.DECISIVE_BATTLE.value + if result is None: + return PageMatch(name=name, matched=False, score=0.0) + return PageMatch(name=name, matched=True, score=result.confidence) # ── 小关进度识别 ────────────────────────────────────────────────────── @staticmethod - def recognize_stage(screen: np.ndarray, chapter: int) -> int: - """识别当前决战章节的小关进度 (0-3)。 + def recognize_stage(screen: np.ndarray, chapter: int) -> int | None: + """识别当前决战章节的小关进度 (1-3) 或章节已完成。 - 检查每个小关位置像素颜色,白色 (250,244,253) 为已通过。 - 返回当前正在进行的小关编号; 3 表示全部通过。 + 三个像素点只负责确认小节节点是否存在。三个节点都存在时, + 再用入口状态和重置按钮区分第三节进行中与三节全部完成。 """ check_points = _STAGE_CHECK_POINTS.get(chapter) if check_points is None: _log.warning('[决战] 决战 recognize_stage: 未知章节 {}', chapter) return 0 - for i, (rx, ry) in enumerate(check_points): - if not PixelChecker.check_pixel( + node_exists = [ + PixelChecker.check_pixel( screen, rx, ry, _STAGE_CHECK_COLOR, _STAGE_CHECK_TOLERANCE, - ): - _log.info('[决战] 识别决战地图参数, 第 {} 小节正在进行', i) - return i + ) + for rx, ry in check_points + ] - _log.info('[决战] 识别决战地图参数, 第 3 小节正在进行') - return 3 + if not node_exists[0]: + _log.warning('[决战] 小节节点进度异常: 第 1 个节点不存在') + return 0 + if not node_exists[1]: + _log.info('[决战] 识别决战地图参数, 第 1 小节正在进行') + return 1 + if not node_exists[2]: + _log.info('[决战] 识别决战地图参数, 第 2 小节正在进行') + return 2 + + entry_refresh = ImageChecker.template_exists( + screen, + Templates.Decisive.ENTRY_REFRESH, + roi=ENTRY_STATUS_ROI, + confidence=0.8, + ) + if entry_refresh: + _log.info('[决战] 入口为可重置状态,三个小节均已完成') + return None - def detect_stage(self, screen: np.ndarray, chapter: int) -> int: + entry_challenging = ImageChecker.template_exists( + screen, + Templates.Decisive.ENTRY_CHALLENGING, + roi=ENTRY_STATUS_ROI, + confidence=0.8, + ) + reset_button = ImageChecker.template_exists( + screen, + Templates.Decisive.RESET_BUTTON, + roi=RESET_BUTTON_ROI, + confidence=0.8, + ) + if entry_challenging and reset_button: + _log.info('[决战] 入口仍在挑战中且存在重置按钮,第 3 小节正在进行') + return 3 + + _log.warning('[决战] 三个小节节点均存在,但入口状态无法确认第三节或完成状态') + return 0 + + def detect_stage(self, screen: np.ndarray, chapter: int) -> int | None: """识别小节号(统一调用 recognize_stage)。""" return self.recognize_stage(screen, chapter) @@ -396,6 +462,7 @@ def detect_entry_status( detail = ImageChecker.find_any( screen, templates, + roi=ENTRY_STATUS_ROI, confidence=confidence, ) if detail is not None: @@ -422,17 +489,74 @@ def reset_chapter(self) -> bool: 船坞已满处理由调用方负责。 """ _log.info('[决战] 决战页面 → 重置关卡') - self._ctrl.click(*CLICK_RESET_CHAPTER) - time.sleep(1.0) - screen = self._ctrl.screenshot() - if ImageChecker.template_exists( - screen, - Templates.Build.SHIP_FULL_DEPOT, - confidence=0.8, - ): - _log.warning('[决战] 重置关卡时检测到船坞已满') - return False - confirm_operation(self._ctrl, must_confirm=True, timeout=5.0) - time.sleep(1.0) # 防止后续 stage 识别出问题 - _log.info('[决战] 决战关卡重置完成') - return True + deadline = time.monotonic() + 5.0 + match = None + match_source = None + while time.monotonic() < deadline: + screen = self._ctrl.screenshot() + match = ImageChecker.find_template( + screen, + Templates.Decisive.RESET_BUTTON, + roi=RESET_BUTTON_ROI, + confidence=0.8, + ) + match_source = 'reset_button' if match is not None else None + if match is None: + match = ImageChecker.find_template( + screen, + Templates.Decisive.ENTRY_REFRESH, + roi=RESET_ENTRY_ROI, + confidence=0.8, + ) + match_source = 'entry_refresh' if match is not None else None + if match is not None: + break + time.sleep(0.2) + + if match is None: + raise TimeoutError('未识别到「重置关卡」按钮,拒绝点击') + + for attempt in range(2): + if attempt: + screen = self._ctrl.screenshot() + match = ImageChecker.find_template( + screen, + Templates.Decisive.RESET_BUTTON, + roi=RESET_BUTTON_ROI, + confidence=0.8, + ) + match_source = 'reset_button' if match is not None else None + if match is None: + match = ImageChecker.find_template( + screen, + Templates.Decisive.ENTRY_REFRESH, + roi=RESET_ENTRY_ROI, + confidence=0.8, + ) + match_source = 'entry_refresh' if match is not None else None + if match is None: + raise TimeoutError('重试时未识别到「重置关卡」按钮,拒绝点击') + _log.warning('[决战] 确认弹窗未出现,重新识别并点击重置入口') + + _log.info('[决战] 识别到重置入口: {}', match_source) + self._ctrl.click(*match.center) + time.sleep(1.0) + screen = self._ctrl.screenshot() + if ImageChecker.template_exists( + screen, + Templates.Build.SHIP_FULL_DEPOT, + confidence=0.8, + ): + _log.warning('[决战] 重置关卡时检测到船坞已满') + return False + try: + confirm_operation(self._ctrl, must_confirm=True, timeout=5.0) + except NavigationError: + if attempt == 1: + raise + continue + time.sleep(1.0) # 防止后续 stage 识别出问题 + _log.info('[决战] 决战关卡重置完成') + return True + + raise TimeoutError('重置关卡确认失败') diff --git a/autowsgr/ui/decisive/fleet_ocr.py b/autowsgr/ui/decisive/fleet_ocr.py index b3d97543..3bdca4ee 100644 --- a/autowsgr/ui/decisive/fleet_ocr.py +++ b/autowsgr/ui/decisive/fleet_ocr.py @@ -63,19 +63,6 @@ def _prepare_text_roi(image: np.ndarray, *, scale: int = 4) -> np.ndarray: return cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB) -def _prepare_name_roi(image: np.ndarray) -> np.ndarray: - """舰名区域做温和增强,保留字形细节。""" - if image.size == 0: - return image - - enlarged = cv2.resize(image, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC) - lab = cv2.cvtColor(enlarged, cv2.COLOR_RGB2LAB) - lightness_channel, a, b = cv2.split(lab) - lightness_channel = cv2.equalizeHist(lightness_channel) - enhanced = cv2.merge((lightness_channel, a, b)) - return cv2.cvtColor(enhanced, cv2.COLOR_LAB2RGB) - - def recognize_fleet_options( ocr: OCREngine, screen: np.ndarray, @@ -97,6 +84,7 @@ def recognize_fleet_options( _log.debug('[舰队OCR] 开始识别战备舰队可选项') # 1. 识别可用分数 + # TODO(decisive-architecture): govern score/cost/name OCR at the UI architecture layer. res_roi = ROI( x1=RESOURCE_AREA[0][0], y1=RESOURCE_AREA[1][1], diff --git a/autowsgr/ui/decisive/map_controller.py b/autowsgr/ui/decisive/map_controller.py index 5b2f393f..8e6831de 100644 --- a/autowsgr/ui/decisive/map_controller.py +++ b/autowsgr/ui/decisive/map_controller.py @@ -23,8 +23,11 @@ from autowsgr.infra.logger import get_logger from autowsgr.types import DecisivePhase, FleetSelection, ShipDamageState from autowsgr.ui.battle.preparation import BattlePreparationPage, RepairStrategy +from autowsgr.ui.decisive.battle_page import ENTRY_STATUS_ROI from autowsgr.ui.decisive.overlay import ( ADVANCE_CARD_POSITIONS, + ADVANCE_CHOICE_ROI, + ADVANCE_CHOICE_THREE_ROI, CLICK_ADVANCE_CONFIRM, CLICK_FLEET_CLOSE, CLICK_FLEET_REFRESH, @@ -33,15 +36,19 @@ CLICK_RETREAT_BUTTON, CLICK_RETREAT_CONFIRM, CLICK_SORTIE, + FLEET_ACQUISITION_ROI, + FLEET_NAME_ROI, + USE_LAST_FLEET_ROI, DecisiveOverlay, detect_decisive_overlay, - get_overlay_signature, + get_overlay_template, is_decisive_map_page, is_fleet_acquisition, ) from autowsgr.ui.decisive.preparation import DecisiveBattlePreparationPage from autowsgr.ui.utils.ship_list import recognize_ships_in_list as _recognize_ships from autowsgr.vision import ( + ROI, ImageChecker, MatchStrategy, PixelChecker, @@ -123,6 +130,9 @@ def is_skill_used(self) -> bool: def detect_decisive_phase( # noqa: PLR0911 self, screen: np.ndarray | None = None, + *, + advance_choice_roi: ROI | None = None, + allow_fleet_overlay: bool = True, ) -> DecisivePhase | None: """单次截图检测当前决战页面状态。 @@ -154,17 +164,29 @@ def detect_decisive_phase( # noqa: PLR0911 if ImageChecker.template_exists( screen, Templates.Decisive.USE_LAST_FLEET, + roi=USE_LAST_FLEET_ROI, confidence=0.8, ): _log.info('[地图控制器] 检测到「使用上次舰队」按钮') return DecisivePhase.USE_LAST_FLEET - overlay = detect_decisive_overlay(screen) + overlay = detect_decisive_overlay( + screen, + advance_choice_roi=advance_choice_roi, + include_fleet_acquisition=allow_fleet_overlay, + ) if overlay is not None: if overlay == DecisiveOverlay.ADVANCE_CHOICE: return DecisivePhase.ADVANCE_CHOICE if overlay == DecisiveOverlay.FLEET_ACQUISITION: - return DecisivePhase.CHOOSE_FLEET + # Closing the fleet dialog can leave one stale scrcpy frame that + # still matches the overlay. Require a fresh confirmation before + # routing back into OCR, otherwise the state machine can wait + # for a dialog that has already disappeared. + time.sleep(0.2) + if is_fleet_acquisition(self._ctrl.screenshot()): + return DecisivePhase.CHOOSE_FLEET + _log.debug('[地图控制器] 丢弃过期的战备舰队弹窗匹配') if is_decisive_map_page(screen): # 进图后的首个稳定帧有时会短暂满足“地图页”特征,但战备舰队 @@ -173,7 +195,11 @@ def detect_decisive_phase( # noqa: PLR0911 time.sleep(0.2) confirm_screen = self._ctrl.screenshot() - overlay = detect_decisive_overlay(confirm_screen) + overlay = detect_decisive_overlay( + confirm_screen, + advance_choice_roi=advance_choice_roi, + include_fleet_acquisition=allow_fleet_overlay, + ) if overlay is not None: if overlay == DecisiveOverlay.ADVANCE_CHOICE: _log.debug('[地图控制器] 地图页复检修正为 overlay: advance_choice') @@ -187,6 +213,60 @@ def detect_decisive_phase( # noqa: PLR0911 return None + def _wait_for_use_last_fleet(self) -> bool: + """等待总览稳定后,在固定按钮区域快速识别三次。""" + from autowsgr.image_resources import Templates + + time.sleep(3.0) + for _ in range(3): + if ImageChecker.template_exists( + self._ctrl.screenshot(), + Templates.Decisive.USE_LAST_FLEET, + roi=USE_LAST_FLEET_ROI, + confidence=0.8, + ): + return True + time.sleep(0.2) + return False + + def wait_for_entry_phase( + self, + *, + wait_for_use_last: bool, + wait_for_advance: bool, + wait_for_fleet: bool = True, + advance_choice_roi: ROI | None = None, + timeout: float = 3.0, + interval: float = 0.2, + ) -> DecisivePhase: + """Resolve the next entry UI through staged, positive recognition. + + The caller supplies which already-consumed overlays to skip. The final + stage accepts only a fleet-acquisition overlay or a confirmed map page; + unknown screens time out instead of receiving a blind click. + """ + + if wait_for_use_last and self._wait_for_use_last_fleet(): + return DecisivePhase.USE_LAST_FLEET + + if wait_for_advance and self._wait_for_advance_choice( + advance_choice_roi, + timeout=timeout, + interval=interval, + ): + return DecisivePhase.ADVANCE_CHOICE + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + screen = self._ctrl.screenshot() + if wait_for_fleet and is_fleet_acquisition(screen): + return DecisivePhase.CHOOSE_FLEET + if is_decisive_map_page(screen): + return DecisivePhase.PREPARE_COMBAT + time.sleep(interval) + + raise TimeoutError('决战入口页面未识别到预期弹窗或地图页') + # ── 舰船图标颜色检测参数 (HSV, BGR 输入) ────────────────────── # 决战地图上的舰船指示器呈橙黄色高亮,是该色段面积最大的连通区域 _SHIP_HSV_LO = np.array([18, 100, 200], dtype=np.uint8) @@ -378,6 +458,7 @@ def close_fleet_overlay(self) -> bool: while time.monotonic() < deadline: screen = self._ctrl.screenshot() if not is_fleet_acquisition(screen): + time.sleep(1.5) return True time.sleep(0.2) _log.warning('[地图控制器] 关闭战备舰队弹窗后仍停留在原界面') @@ -408,15 +489,24 @@ def click_use_last_fleet(self) -> None: click_confirm_pos: tuple[float, float] = (873 / 960, 500 / 540) - screen = self._ctrl.screenshot() - match = ImageChecker.find_template( - screen, - Templates.Decisive.USE_LAST_FLEET, - confidence=0.8, - ) - if match is not None: - self._ctrl.click(*match.center) - time.sleep(0.5) + deadline = time.monotonic() + 5.0 + match = None + while time.monotonic() < deadline: + match = ImageChecker.find_template( + self._ctrl.screenshot(), + Templates.Decisive.USE_LAST_FLEET, + roi=USE_LAST_FLEET_ROI, + confidence=0.8, + ) + if match is not None: + break + time.sleep(0.2) + + if match is None: + raise TimeoutError('未识别到「使用上次舰队」按钮,拒绝点击') + + self._ctrl.click(*match.center) + time.sleep(0.5) self._ctrl.click(*click_confirm_pos) time.sleep(1.0) @@ -427,6 +517,8 @@ def use_skill(self) -> list[str]: def check_fleet( self, + *, + scan_ship_pool: bool = False, ) -> tuple[list[str | None], dict[int, ShipDamageState], set[str]]: """恢复进度时在编队页面扫描当前编队及所有可用舰船。 @@ -458,6 +550,11 @@ def check_fleet( fleet = page.detect_fleet(screen) damage = page.detect_ship_damage(screen) + current_ships = {name for name in fleet if name} + if current_ships and not scan_ship_pool: + _log.info('[Decisive] current formation is not empty; skip ship-pool scan') + return fleet, damage, current_ships + # 进入选船列表:先确认已离开出征准备页,再进行一次识别 page.click_ship_slot(0) deadline = time.monotonic() + 5.0 @@ -489,12 +586,8 @@ def check_fleet( self._ctrl.click(0.05, 0.05) time.sleep(1.0) - # 返回地图页 - page.go_back() - time.sleep(1.0) - _log.info( - '[地图控制器] 编队={}, 可用舰船={}', + '[地图控制器] 编队={}, 可用舰船={} (停留在编队页)', fleet, sorted(all_ships), ) @@ -504,8 +597,17 @@ def check_fleet( # 选择前进点 overlay # ══════════════════════════════════════════════════════════════════════ - def select_advance_card(self, index: int) -> None: + def select_advance_card( + self, + index: int, + *, + advance_choice_roi: ROI | None = None, + ) -> None: """选择前进点卡片并确认。""" + wait_kwargs = {'timeout': 5.0, 'interval': 0.2} + if advance_choice_roi is not None: + wait_kwargs['advance_choice_roi'] = advance_choice_roi + self.wait_for_overlay(DecisiveOverlay.ADVANCE_CHOICE, **wait_kwargs) if index < len(ADVANCE_CARD_POSITIONS): self._ctrl.click(*ADVANCE_CARD_POSITIONS[index]) time.sleep(0.5) @@ -520,31 +622,60 @@ def enter_formation(self) -> None: """点击右下角「编队」按钮。""" # TODO: 改进鲁棒性 time.sleep(1) + # 调试:检测当前页面状态 + from autowsgr.image_resources import Templates from autowsgr.ui.utils.navigation import NavConfig - # 调试:检测当前页面状态 screen = self._ctrl.screenshot() - from autowsgr.ui.battle.base import PAGE_SIGNATURE - from autowsgr.ui.decisive.overlay import SIG_MAP_PAGE - from autowsgr.vision.matcher import PixelChecker + if not is_decisive_map_page(screen): + _log.warning('[地图控制器] 点击编队前未识别到决战地图页,拒绝点击') + raise TimeoutError('点击编队前未识别到决战地图页') - map_check = PixelChecker.check_signature(screen, SIG_MAP_PAGE) - prep_check = PixelChecker.check_signature(screen, PAGE_SIGNATURE) + map_check = ImageChecker.template_exists( + screen, Templates.Decisive.MAP_PAGE, confidence=0.85 + ) + prep_check = ImageChecker.template_exists( + screen, Templates.Page.BATTLE_PREP, confidence=0.85 + ) _log.debug( '[地图控制器] 点击编队前 - 地图页: {}, 出征准备页: {}', - map_check.matched, - prep_check.matched, + map_check, + prep_check, ) config = NavConfig(timeout=10.0, interval=0.5, max_retries=3) - click_and_wait_for_page( - self._ctrl, - CLICK_FORMATION, - BattlePreparationPage.is_current_page, - config=config, - source='决战地图', - target='出征准备', - ) + for attempt in range(2): + click_and_wait_for_page( + self._ctrl, + CLICK_FORMATION, + BattlePreparationPage.is_current_page, + config=config, + source='决战地图', + target='出征准备', + ) + if self._wait_for_fleet_name(): + return + if attempt == 0: + _log.warning('[地图控制器] 出征准备页未识别到决战舰队标题,返回地图后重试') + self.go_to_map_page() + time.sleep(0.5) + + raise TimeoutError('进入决战出征准备页后未识别到「主力决战舰队」标题') + + def _wait_for_fleet_name(self) -> bool: + """在固定 ROI 内连续识别三次决战编队标题。""" + from autowsgr.image_resources import Templates + + for _ in range(3): + if ImageChecker.template_exists( + self._ctrl.screenshot(), + Templates.Decisive.FLEET_NAME, + roi=FLEET_NAME_ROI, + confidence=0.8, + ): + return True + time.sleep(0.2) + return False def click_sortie(self) -> None: """点击右下角「出征」按钮。""" @@ -632,7 +763,15 @@ def confirm_stage_clear(self) -> list[str]: # noqa: PLR0912 reward_ack_pos = (0.953, 0.954) while time.monotonic() < settle_deadline: screen = self._ctrl.screenshot() - if ImageChecker.find_any(screen, entry_templates, confidence=0.8) is not None: + if ( + ImageChecker.find_any( + screen, + entry_templates, + roi=ENTRY_STATUS_ROI, + confidence=0.8, + ) + is not None + ): _log.info('[地图控制器] 小关通关结算完成,已回到决战入口页') break @@ -655,7 +794,15 @@ def confirm_stage_clear(self) -> list[str]: # noqa: PLR0912 # settle 循环结束后,如果仍未回到入口页,尝试从地图页返回 for _ in range(5): screen = self._ctrl.screenshot() - if ImageChecker.find_any(screen, entry_templates, confidence=0.8) is not None: + if ( + ImageChecker.find_any( + screen, + entry_templates, + roi=ENTRY_STATUS_ROI, + confidence=0.8, + ) + is not None + ): _log.info('[地图控制器] 通过返回按钮回到决战入口页') break _log.debug('[地图控制器] 尝试点击返回按钮回到决战入口页') @@ -723,14 +870,74 @@ def wait_for_overlay( target: DecisiveOverlay, timeout: float = 5.0, interval: float = 0.3, + *, + advance_choice_roi: ROI | None = None, ) -> np.ndarray: """反复截图直到指定 overlay 出现。""" - sig = get_overlay_signature(target) + tmpl = get_overlay_template(target) + confidence = ( + 0.70 + if target is DecisiveOverlay.FLEET_ACQUISITION + else 0.80 + if target is DecisiveOverlay.ADVANCE_CHOICE + else 0.85 + ) deadline = time.monotonic() + timeout while True: screen = self._ctrl.screenshot() - if PixelChecker.check_signature(screen, sig): + if target is DecisiveOverlay.ADVANCE_CHOICE: + rois = self._advance_choice_rois(advance_choice_roi) + matched = any( + ImageChecker.template_exists(screen, tmpl, roi=roi, confidence=confidence) + for roi in rois + ) + else: + matched = ImageChecker.template_exists( + screen, + tmpl, + roi=( + FLEET_ACQUISITION_ROI + if target is DecisiveOverlay.FLEET_ACQUISITION + else None + ), + confidence=confidence, + ) + if matched: return screen if time.monotonic() >= deadline: raise TimeoutError(f'等待 overlay {target.value} 超时 ({timeout}s)') time.sleep(interval) + + @staticmethod + def _advance_choice_rois(advance_choice_roi: ROI | None) -> tuple[ROI, ...]: + if advance_choice_roi is not None: + # The map graph may report three successors while the live UI + # filters the popup down to two cards. + return (advance_choice_roi, ADVANCE_CHOICE_ROI, ADVANCE_CHOICE_THREE_ROI) + return (ADVANCE_CHOICE_ROI, ADVANCE_CHOICE_THREE_ROI) + + def _wait_for_advance_choice( + self, + advance_choice_roi: ROI | None, + *, + timeout: float, + interval: float, + ) -> bool: + from autowsgr.image_resources import Templates + + deadline = time.monotonic() + timeout + rois = self._advance_choice_rois(advance_choice_roi) + while time.monotonic() < deadline: + screen = self._ctrl.screenshot() + if any( + ImageChecker.template_exists( + screen, + Templates.Decisive.ADVANCE_CHOICE, + roi=roi, + confidence=0.80, + ) + for roi in rois + ): + return True + time.sleep(interval) + return False diff --git a/autowsgr/ui/decisive/overlay.py b/autowsgr/ui/decisive/overlay.py index d659f9ee..27b5806b 100644 --- a/autowsgr/ui/decisive/overlay.py +++ b/autowsgr/ui/decisive/overlay.py @@ -15,8 +15,12 @@ import enum from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.vision import ( + ROI, + ImageChecker, + ImageTemplate, MatchStrategy, PixelChecker, PixelRule, @@ -119,6 +123,42 @@ class DecisiveOverlay(enum.Enum): _SIG_BY_TYPE: dict[DecisiveOverlay, PixelSignature] = dict(OVERLAY_SIGNATURES) +# 720p 决战总览页右侧「上次选船」按钮区域,按用户实机红框留出边缘。 +USE_LAST_FLEET_ROI = ROI(0.82, 0.30, 1.0, 0.50).expand_pixels(1280, 720) + +# 1280x720 决战出征准备页「主力决战舰队」标题区域。 +FLEET_NAME_ROI = ROI(0.08, 0.11, 0.26, 0.22).expand_pixels(1280, 720) + +# Keep a 1px margin around the 305x69 title template. +FLEET_ACQUISITION_ROI = ROI(494 / 1280, 38 / 720, 799 / 1280, 107 / 720).expand_pixels( + 1280, 720 +) + +# 1280x720 确认退出弹窗的固定红框区域(confirm_exit_720p.png)。 +CONFIRM_EXIT_ROI = ROI(363 / 1280, 161 / 720, 917 / 1280, 465 / 720).expand_pixels( + 1280, 720 +) + +# 1280x720 决战「选择前进点」左侧卡片区域。 +ADVANCE_CHOICE_ROI = ROI(324 / 1280, 237 / 720, 607 / 1280, 429 / 720).expand_pixels( + 1280, 720 +) + +# 1280x720 三分支「选择前进点」左侧卡片区域(adb-teamchose3.png)。 +ADVANCE_CHOICE_THREE_ROI = ROI( + 142 / 1280, 235 / 720, 429 / 1280, 431 / 720 +).expand_pixels(1280, 720) + +# overlay → 识别模板映射 (图像模板匹配, 替代上方像素签名) +_OVERLAY_TEMPLATE_MAP: dict[DecisiveOverlay, ImageTemplate] = { + DecisiveOverlay.FLEET_ACQUISITION: Templates.Decisive.FLEET_ACQUISITION, + DecisiveOverlay.CONFIRM_EXIT: Templates.Decisive.CONFIRM_EXIT, + DecisiveOverlay.ADVANCE_CHOICE: Templates.Decisive.ADVANCE_CHOICE, +} + +_FLEET_ACQUISITION_CONFIDENCE = 0.70 +_ADVANCE_CHOICE_CONFIDENCE = 0.80 + # ═══════════════════════════════════════════════════════════════════════════════ # 坐标常量 (相对坐标 0.0-1.0, 参考分辨率 960x540) @@ -135,13 +175,6 @@ class DecisiveOverlay(enum.Enum): CLICK_FORMATION: tuple[float, float] = (700 / 960, 500 / 540) """右下角「编队」按钮 — 进入编队页面。""" -CLICK_BUY_EXP: tuple[float, float] = (75 / 960, 500 / 540) -"""左下角「购买经验值」按钮。""" - -CLICK_SKILL: tuple[float, float] = (0.2143, 0.894) -"""副官技能按钮。""" - - # ── 战备舰队获取 overlay ── CLICK_FLEET_REFRESH: tuple[float, float] = (380 / 960, 500 / 540) @@ -206,7 +239,12 @@ class DecisiveOverlay(enum.Enum): # ═══════════════════════════════════════════════════════════════════════════════ -def detect_decisive_overlay(screen: np.ndarray) -> DecisiveOverlay | None: +def detect_decisive_overlay( + screen: np.ndarray, + *, + advance_choice_roi: ROI | None = None, + include_fleet_acquisition: bool = True, +) -> DecisiveOverlay | None: """按优先级检测决战地图页上的弹窗。 Parameters @@ -219,8 +257,42 @@ def detect_decisive_overlay(screen: np.ndarray) -> DecisiveOverlay | None: DecisiveOverlay | None 首个命中的弹窗类型;无弹窗则返回 ``None``。 """ - for overlay_type, sig in OVERLAY_SIGNATURES: - if PixelChecker.check_signature(screen, sig): + for overlay_type, tmpl in _OVERLAY_TEMPLATE_MAP.items(): + if overlay_type is DecisiveOverlay.FLEET_ACQUISITION and not include_fleet_acquisition: + continue + confidence = ( + _FLEET_ACQUISITION_CONFIDENCE + if overlay_type is DecisiveOverlay.FLEET_ACQUISITION + else _ADVANCE_CHOICE_CONFIDENCE + if overlay_type is DecisiveOverlay.ADVANCE_CHOICE + else 0.85 + ) + if overlay_type is DecisiveOverlay.ADVANCE_CHOICE: + rois = ( + # Map data describes available branches, but the live UI can + # render fewer cards after route filtering. + (advance_choice_roi, ADVANCE_CHOICE_ROI, ADVANCE_CHOICE_THREE_ROI) + if advance_choice_roi is not None + else (ADVANCE_CHOICE_ROI, ADVANCE_CHOICE_THREE_ROI) + ) + matched = any( + ImageChecker.template_exists(screen, tmpl, roi=roi, confidence=confidence) + for roi in rois + ) + else: + matched = ImageChecker.template_exists( + screen, + tmpl, + roi=( + FLEET_ACQUISITION_ROI + if overlay_type is DecisiveOverlay.FLEET_ACQUISITION + else CONFIRM_EXIT_ROI + if overlay_type is DecisiveOverlay.CONFIRM_EXIT + else None + ), + confidence=confidence, + ) + if matched: _log.debug('[决战] 检测到 overlay: {}', overlay_type.value) return overlay_type return None @@ -233,19 +305,19 @@ def is_decisive_map_page(screen: np.ndarray) -> bool: def is_fleet_acquisition(screen: np.ndarray) -> bool: """截图是否为战备舰队获取 overlay。""" - return PixelChecker.check_signature(screen, SIG_FLEET_ACQUISITION).matched - - -def is_advance_choice(screen: np.ndarray) -> bool: - """截图是否为选择前进点 overlay。""" - return PixelChecker.check_signature(screen, SIG_ADVANCE_CHOICE).matched + return ImageChecker.template_exists( + screen, + Templates.Decisive.FLEET_ACQUISITION, + roi=FLEET_ACQUISITION_ROI, + confidence=_FLEET_ACQUISITION_CONFIDENCE, + ) -def is_confirm_exit(screen: np.ndarray) -> bool: - """截图是否为确认退出 overlay。""" - return PixelChecker.check_signature(screen, SIG_CONFIRM_EXIT).matched +def get_overlay_template(overlay: DecisiveOverlay) -> ImageTemplate: + """按类型获取对应的识别模板。""" + return _OVERLAY_TEMPLATE_MAP[overlay] def get_overlay_signature(overlay: DecisiveOverlay) -> PixelSignature: - """按类型获取对应的像素签名。""" + """按类型获取对应的像素签名 (保留备查, 不再参与判定)。""" return _SIG_BY_TYPE[overlay] diff --git a/autowsgr/ui/decisive/preparation.py b/autowsgr/ui/decisive/preparation.py index 2e88117f..2d168af7 100644 --- a/autowsgr/ui/decisive/preparation.py +++ b/autowsgr/ui/decisive/preparation.py @@ -20,8 +20,12 @@ from typing import TYPE_CHECKING from autowsgr.combat.fleet import exact_fleet_rules +from autowsgr.types import PageName +from autowsgr.ui.battle.constants import CLICK_BACK from autowsgr.ui.battle.preparation import BattlePreparationPage from autowsgr.ui.decisive.legacy_fleet_change import change_fleet_legacy +from autowsgr.ui.decisive.overlay import is_decisive_map_page +from autowsgr.ui.utils import click_and_wait_for_page if TYPE_CHECKING: @@ -51,6 +55,16 @@ class DecisiveBattlePreparationPage(BattlePreparationPage): _use_search: bool = False + def go_back(self) -> None: + """Return to the decisive map using its dedicated page recognizer.""" + click_and_wait_for_page( + self._ctrl, + click_coord=CLICK_BACK, + checker=is_decisive_map_page, + source=PageName.BATTLE_PREP, + target=PageName.MAP, + ) + def __init__( self, ctx: GameContext, diff --git a/autowsgr/ui/event/event_page.py b/autowsgr/ui/event/event_page.py index 0051c2c4..c2dcb760 100644 --- a/autowsgr/ui/event/event_page.py +++ b/autowsgr/ui/event/event_page.py @@ -28,6 +28,8 @@ from autowsgr.vision import ( Color, ImageChecker, + ImageMatchDetail, + PageMatch, PixelChecker, ) @@ -46,6 +48,23 @@ # ═══════════════════════════════════════════════════════════════════════════════ +@lru_cache(maxsize=1) +def _get_fight_button_templates() -> list[ImageTemplate]: + """关卡详情浮层的"出击"按钮模板。 + + 活动浮层 (点击地图节点后弹出的关卡详情) 右下角的出击按钮。该浮层是模态, + 浮层在 = 按钮必在, 故**按钮可见即浮层态**的充分判据 (战斗回港后活动页 + 常直接落在此浮层态)。按钮是流程必按控件, 逐活动截一张 (与浮层整体外观 + 相比, 按钮样式跨活动差异小), 且匹配位置 ``center`` 可直接用作点击坐标, + 布局微调也无需改坐标常量。 + """ + from autowsgr.image_resources._lazy import load_template + + return [ + load_template('event/fight_button_20260730_540p.png', name='fight_button'), + ] + + @lru_cache(maxsize=1) def _get_event_title_templates() -> list[ImageTemplate]: """活动地图页面标题图模板。 @@ -103,29 +122,6 @@ def _get_difficulty_easy_templates() -> list[ImageTemplate]: ] -# ── 节点详情浮层"出击准备"按钮图标 (延迟加载) ─────────────────────────── -# 点击地图节点后弹出关卡详情浮层, 浮层右下角出现"出击准备"按钮 (classic -# event/{date}/1.PNG)。出现该按钮即节点选择成功 —— 照搬 classic -# _go_fight_prepare_page: classic 用 image_exist(event_image[1]) 判断, 而非 -# 识别浮层本身 (浮层外观随活动主题变化, 按钮图标更鲁棒)。各活动 1.PNG 均为 -# 此按钮但外观随主题变, 故按日期命名。 -# classic 把截图 cv2.resize 到 960x540 后匹配, 540p 模板用默认 source_resolution。 - - -@lru_cache(maxsize=1) -def _get_fight_button_templates() -> list[ImageTemplate]: - """节点详情浮层上的"出击准备"按钮图标 (classic ``event_image[1]``)。 - - 点击地图节点后, 该节点关卡详情浮层弹出, 浮层右下角出现蓝色"出击准备" - 按钮。检测到该按钮即认为节点选择成功。 - """ - from autowsgr.image_resources._lazy import load_template - - return [ - load_template('event/fight_button_20260730_540p.png', name='fight_btn_20260730'), - ] - - NODE_POSITIONS = { 1: (0.1789, 0.1986), 2: (0.3914, 0.2528), @@ -223,51 +219,62 @@ def __init__( # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: - """判断截图是否为活动地图页面。 - - 用活动标题图模板匹配: 每个活动地图选择页顶部都有活动名标题 (如 - "激斗漩涡"), 仅在该页面显示, ``find_any`` 命中即认定为活动地图页面。 + def is_current_page(screen: np.ndarray) -> PageMatch: + """判断截图是否为活动地图页面 (含浮层态)。 + + 三层锚点, 按优先级: + 1. **出击按钮**可见 → 浮层态 (关卡详情浮层是模态, 浮层在按钮必在)。 + 战斗回港后活动页常直接落在浮层态, 必须算"在活动页", 否则战后 + goto_page(EVENT_MAP) 会导航失败。 + 2. **难度切换图标**可见 → 干净活动页。 + 3. **活动标题**命中 → 兜底 (仅证明"在活动域", 不区分浮层与否)。 + + 出击按钮/难度图标是流程必交互控件, 按钮样式跨活动差异小; 标题图 + 逐活动重截但仅作兜底, 缺失时新活动仍可识别 (前两层已覆盖)。 + 返回带置信度的 PageMatch, 供候选集排序。 """ - return ( - ImageChecker.find_any(screen, _get_event_title_templates(), confidence=0.8) is not None - ) + name = str(PageName.EVENT_MAP) + button = BaseEventPage._fight_button_detail(screen) + if button is not None: + return PageMatch(name=name, matched=True, score=button.confidence) + icon_state = BaseEventPage._difficulty_icon_state(screen) + if icon_state is not None: + return PageMatch(name=name, matched=True, score=0.9) + title = ImageChecker.find_any(screen, _get_event_title_templates(), confidence=0.8) + if title is not None: + return PageMatch(name=name, matched=True, score=title.confidence * 0.95) + return PageMatch(name=name, matched=False, score=0.0) + + @staticmethod + def _fight_button_detail(screen: np.ndarray) -> ImageMatchDetail | None: + """查找出击按钮 (浮层态锚点), 返回匹配详情 (``None`` = 浮层未开)。""" + return ImageChecker.find_any(screen, _get_fight_button_templates(), confidence=0.8) # ── 节点选择 ────────────────────────────────────────────────────────── def _enter_node(self, node_id: int) -> None: """点击选择地图节点, 等待节点详情浮层弹出。 - 照搬 classic ``_go_fight_prepare_page``: 通过检测浮层上的"出击准备"按钮 - 图标是否出现来确认节点选择成功, 而非识别浮层本身 (浮层外观随活动主题 - 变化, 按钮图标更鲁棒)。 - - 若出击按钮已在屏幕 (节点已选), 直接返回不重复点击 (同 classic - ``if not image_exist(event_image[1])`` 的短路逻辑)。 + 通过**出击按钮出现**确认节点选择成功: 关卡详情浮层是模态, 浮层在 = + 其右下角的出击按钮必在 (见 :func:`_get_fight_button_templates`)。 + 单帧模板匹配即可判定, 不依赖点击前后的时序 (此前用双帧浮层检测, + 需要点击前截干净参照帧, 时序不好会误判)。 Parameters ---------- node_id: 节点编号, 通常为 1~6。 """ - fight_btns = _get_fight_button_templates() - # 出击按钮已在屏幕 → 节点已选好, 无需重复点击 - if ImageChecker.find_any(self._ctrl.screenshot(), fight_btns, confidence=0.8) is not None: - _log.debug('[UI] 活动地图: 出击按钮已存在, 节点已选, 跳过点击') - return positions = NODE_POSITIONS_BY_EVENT.get(self._event_name, NODE_POSITIONS) x, y = positions[node_id] _log.debug('[UI] 活动地图: 选择节点 {}', node_id) self._ctrl.click(x, y) for _ in range(10): - # 出击按钮出现 = 节点详情浮层已弹出 = 选择成功 - if ( - ImageChecker.find_any(self._ctrl.screenshot(), fight_btns, confidence=0.8) - is not None - ): - break + after = self._ctrl.screenshot() + if self._fight_button_detail(after) is not None: + break # 出击按钮出现 = 节点详情浮层弹出 = 选择成功 time.sleep(0.25) else: - raise ActionFailedError(f'活动地图: 选择节点 {node_id} 失败,未出现出击按钮浮层') + raise ActionFailedError(f'活动地图: 选择节点 {node_id} 失败,未出现节点详情浮层') # ── 出击 ────────────────────────────────────────────────────────────── @@ -287,6 +294,7 @@ def start_fight( if entrance not in ('alpha', 'beta', None): raise ValueError(f'无效的入口标识: {entrance}') difficulty, node_id = map[0], int(map[1]) + self.ensure_no_overlay() self._change_difficulty(difficulty) self._enter_node(node_id) if entrance is not None: @@ -294,10 +302,18 @@ def start_fight( from autowsgr.ui.battle.preparation import BattlePreparationPage + # 优先点击模板匹配位置 (跨活动布局变化免疫), 匹配失败回退固定坐标 + screen = self._ctrl.screenshot() + detail = self._fight_button_detail(screen) + if detail is not None: + coord = detail.center + else: + coord = CLICK_FIGHT_BUTTON + _log.warning('[UI] 活动地图: 未匹配到出击按钮, 回退固定坐标点击') _log.debug('[UI] 活动地图: 点击出击') click_and_wait_for_page( self._ctrl, - click_coord=CLICK_FIGHT_BUTTON, + click_coord=coord, checker=BattlePreparationPage.is_current_page, source=PageName.EVENT_MAP, target=PageName.BATTLE_PREP, @@ -321,6 +337,24 @@ def _get_difficulty(self) -> str: ``"H"`` (困难) 或 ``"E"`` (简单)。 """ screen = self._ctrl.screenshot() + icon_state = self._difficulty_icon_state(screen) + if icon_state is not None: + return icon_state + if self.is_current_page(screen): + _log.info('[UI] 活动地图: 未检测到难度切换图标, 判定为简单 (可能简单未通关/未解锁困难)') + return 'E' + raise ActionFailedError('活动地图: 无法识别当前难度且不在活动页面') + + @staticmethod + def _difficulty_icon_state(screen: np.ndarray) -> str | None: + """从截图判定难度切换图标状态。 + + Returns + ------- + str | None + ``"E"`` (看到"切困难"按钮, 当前简单) / ``"H"`` (看到"切简单"按钮, + 当前困难) / ``None`` (图标不可见 — 被浮层遮挡或不在活动页)。 + """ if ( ImageChecker.find_any(screen, _get_difficulty_hard_templates(), confidence=0.8) is not None @@ -331,10 +365,29 @@ def _get_difficulty(self) -> str: is not None ): return 'H' - if self.is_current_page(screen): - _log.info('[UI] 活动地图: 未检测到难度切换图标, 判定为简单 (可能简单未通关/未解锁困难)') - return 'E' - raise ActionFailedError('活动地图: 无法识别当前难度且不在活动页面') + return None + + def ensure_no_overlay(self) -> None: + """确保活动地图页处于干净态 (无关卡详情浮层)。 + + 战斗回港后, 活动页常直接落在关卡详情浮层态 (战后 UI 流转跳过出征 + 准备页直接回浮层)。浮层是模态, 会拦截难度切换/节点选择的点击, 需先 + 关闭才能继续选关。 + + 判定与清理: **出击按钮可见 = 浮层在** (模态浮层在按钮必在) → 点红色 + X 关闭, 以按钮消失确认; 按钮不可见 → 已是干净态, 直接返回。 + """ + screen = self._ctrl.screenshot() + if self._fight_button_detail(screen) is None: + return + for _ in range(3): + self._ctrl.click(*CLICK_CLOSE_NODE_OVERLAY) + time.sleep(0.6) + screen = self._ctrl.screenshot() + if self._fight_button_detail(screen) is None: + _log.info('[UI] 活动地图: 已关闭残留关卡浮层') + return + _log.warning('[UI] 活动地图: 关卡浮层点击 3 次未关闭') def _change_difficulty(self, target: str) -> None: """切换难度到目标。 @@ -392,33 +445,41 @@ def _select_entrance(self, entrance: Literal['alpha', 'beta']) -> None: def go_back(self) -> None: """返回主页面。 - 循环关闭关卡详情浮层 + 点击返回箭头, 逐层退出直到主页面 (照搬 classic - ``go_main_page`` 的"不断点返回键")。战斗结束回港后, 活动地图页上常浮着 - 关卡详情浮层 (红色 X 关闭), 该浮层为模态, 会拦截左上返回箭头的点击 —— - 直接点一次 ``CLICK_BACK`` 会被浮层吸收而无效, 故需循环消浮层/点返回。 + 循环点击返回箭头逐层退出直到主页面。战斗结束回港后, 活动地图页上常浮着 + 关卡详情浮层 (红色 X 关闭), 该浮层为模态, 会拦截左上返回箭头的点击—— + 直接点 ``CLICK_BACK`` 会被浮层吸收而无效。 + + 纯模板驱动判定 (与页面识别同一套锚点): **出击按钮可见 = 浮层在** (模态 + 浮层在按钮必在) → 点红色 X 关闭; 按钮不可见 → 点返回。 - 每轮: 确认是否已到主页 → 若检测到关卡浮层 (出击按钮在屏) 则点红色 X - 关闭 → 否则点左上返回箭头逐层退出; 循环直到主页或超时。 + **防连点过冲**: 点一次返回后用一个轮询窗口 (~3s) 等主页出现, 期间**不重复 + 点返回**。画面切换有滞后, 若点完立即下一轮再点, 第二次返回会落到已切到的 + 主页左上角, 误开"提督信息"浮层 (该浮层不遮主页识别元素 → 识别仍判主页 → + 后续点击错乱)。 """ from autowsgr.ui.main_page import MainPage from autowsgr.ui.utils import NavigationError - fight_btns = _get_fight_button_templates() deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: screen = self._ctrl.screenshot() if MainPage.is_current_page(screen): _log.info('[UI] 活动地图 -> 已到达主页面') return - # 关卡详情浮层挡道 (出击按钮在屏) → 点红色 X 关闭, 下轮重新评估 - if ImageChecker.find_any(screen, fight_btns, confidence=0.8) is not None: - _log.info('[UI] 活动地图: 关卡详情浮层挡道, 点击红色 X 关闭') + if self._fight_button_detail(screen) is not None: + _log.info('[UI] 活动地图: 检测到关卡浮层, 点击红色 X 关闭') self._ctrl.click(*CLICK_CLOSE_NODE_OVERLAY) time.sleep(0.5) continue - # 无浮层 → 点左上返回箭头逐层退出 self._ctrl.click(*CLICK_BACK) - time.sleep(0.5) + # 点一次返回后轮询等主页——期间不重复点返回, 防滞后连点过冲 (见 docstring) + settled = time.monotonic() + 3.0 + while time.monotonic() < settled: + time.sleep(0.3) + if MainPage.is_current_page(self._ctrl.screenshot()): + _log.info('[UI] 活动地图 -> 已到达主页面') + return + # 3s 内未到主页 (画面在变) → 外层循环重新评估 raise NavigationError( '活动地图: 返回主页面超时 (浮层/返回点击未能逐层退出)', screen=self._ctrl.screenshot(), diff --git a/autowsgr/ui/friend_page.py b/autowsgr/ui/friend_page.py index 327553b6..b5a5c27e 100644 --- a/autowsgr/ui/friend_page.py +++ b/autowsgr/ui/friend_page.py @@ -23,6 +23,7 @@ from autowsgr.ui.utils import click_and_wait_for_page from autowsgr.vision import ( MatchStrategy, + PageMatch, PixelRule, PixelSignature, ) @@ -84,19 +85,19 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为好友页面。 - 通过标签页统一检测层判定 (4 标签 + 头部探测点较亮)。 + 通过标签页统一检测层判定 (4 标签 + 头部探测点较亮),返回带覆盖度分数的 PageMatch。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - from autowsgr.ui.tabbed_page import TabbedPageType, identify_page_type + from autowsgr.ui.tabbed_page import TabbedPageType, check_tabbed_page - return identify_page_type(screen) == TabbedPageType.FRIEND + return check_tabbed_page(screen, TabbedPageType.FRIEND) # ── 回退 ────────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/intensify_page.py b/autowsgr/ui/intensify_page.py index 10ef7150..99c9d5e8 100644 --- a/autowsgr/ui/intensify_page.py +++ b/autowsgr/ui/intensify_page.py @@ -23,8 +23,8 @@ from autowsgr.types import PageName from autowsgr.ui.tabbed_page import ( TabbedPageType, + check_tabbed_page, get_active_tab_index, - identify_page_type, make_tab_checker, ) from autowsgr.ui.utils import click_and_wait_for_page @@ -34,6 +34,7 @@ import numpy as np from autowsgr.context import GameContext + from autowsgr.vision import PageMatch _log = get_logger('ui') @@ -99,17 +100,17 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为强化页面组 (含全部 3 个标签)。 - 通过统一标签页检测层识别。 + 通过统一标签页检测层识别,返回带覆盖度分数的 PageMatch。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - return identify_page_type(screen) == TabbedPageType.INTENSIFY + return check_tabbed_page(screen, TabbedPageType.INTENSIFY) @staticmethod def get_active_tab(screen: np.ndarray) -> IntensifyTab | None: diff --git a/autowsgr/ui/main_page/constants.py b/autowsgr/ui/main_page/constants.py index f26894b6..eec51902 100644 --- a/autowsgr/ui/main_page/constants.py +++ b/autowsgr/ui/main_page/constants.py @@ -51,6 +51,7 @@ class OverlayKind(enum.Enum): NEWS = '新闻公告' SIGN = '每日签到' BOOKING = '活动预约' + USER_INFO = '提督信息' # ═══════════════════════════════════════════════════════════════════════════════ @@ -101,6 +102,9 @@ class DismissCoord(enum.Enum): BOOKING = (0.618, 0.564) """预约页面关闭坐标。""" + USER_INFO_CLOSE = (0.877, 0.221) + """提督信息浮层关闭按钮 (右上角 X)""" + @property def xy(self) -> tuple[float, float]: return self.value diff --git a/autowsgr/ui/main_page/controller.py b/autowsgr/ui/main_page/controller.py index 4aadd8eb..d2a52e35 100644 --- a/autowsgr/ui/main_page/controller.py +++ b/autowsgr/ui/main_page/controller.py @@ -7,9 +7,10 @@ import time from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import PageName -from autowsgr.vision import PixelChecker +from autowsgr.vision import ImageChecker, PageMatch, PixelChecker from .constants import ( NavCoord, @@ -92,24 +93,37 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为主页面 (含浮层覆盖)。 参考 :class:`~autowsgr.ui.event.event_page.BaseEventPage` 模式, - 将浮层 (新闻公告 / 每日签到 / 活动预约) 也识别为主页面。 + 将浮层 (新闻公告 / 每日签到 / 活动预约 / 提督信息) 也识别为主页面。 + 基础模板用更高阈值 (0.95) 抑制动态背景误命中, 浮层模板 0.85。 + + 返回带置信度的 :class:`PageMatch` 供候选集排序 + (``PageMatch.__bool__`` 保证旧式真值调用不变)。 """ - if PixelChecker.check_signature(screen, Sig.PAGE.ps).matched: - return True - if PixelChecker.check_signature(screen, Sig.NEWS.ps).matched: - return True - if PixelChecker.check_signature(screen, Sig.SIGN.ps).matched: - return True - return PixelChecker.check_signature(screen, Sig.BOOKING.ps).matched + page = Templates.MainPage + name = PageName.MAIN.value + result = ImageChecker.find_template(screen, page.MAIN, confidence=0.95) + if result is not None: + return PageMatch(name=name, matched=True, score=result.confidence) + overlay = ImageChecker.find_any( + screen, [page.NEWS, page.SIGN, page.BOOKING, page.USER_INFO], confidence=0.85 + ) + if overlay is not None: + return PageMatch(name=name, matched=True, score=overlay.confidence) + # 像素签名兜底: 部分环境渲染差异会使模板得分略低于阈值 (实测远端 0.945 < 0.95), + # 底部栏 4 锚点像素签名可补充识别 (非主页面实测 ratio=0, 无误报风险) + pixel_result = PixelChecker.check_signature(screen, Sig.PAGE.ps) + if pixel_result.matched: + return PageMatch(name=name, matched=True, score=pixel_result.ratio) + return PageMatch(name=name, matched=False, score=0.0) @staticmethod def is_base_page(screen: np.ndarray) -> bool: """判断截图是否为主页面基础状态 (不含浮层)。""" - return PixelChecker.check_signature(screen, Sig.PAGE.ps).matched + return ImageChecker.template_exists(screen, Templates.MainPage.MAIN, confidence=0.85) @staticmethod def detect_overlay(screen: np.ndarray) -> OverlayKind | None: diff --git a/autowsgr/ui/main_page/event_nav.py b/autowsgr/ui/main_page/event_nav.py index 739cc9dc..37cefb8a 100644 --- a/autowsgr/ui/main_page/event_nav.py +++ b/autowsgr/ui/main_page/event_nav.py @@ -9,10 +9,11 @@ from functools import lru_cache from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.vision import ImageChecker -from .constants import NavCoord, Sig, Target +from .constants import NavCoord, Target from .overlays import detect_overlay, dismiss_overlay @@ -76,7 +77,6 @@ def _try_navigate_to_event( bool ``True`` — 成功到达活动页面;``False`` — 本次失败。 """ - from autowsgr.vision import PixelChecker # ① 清除浮层 screen = ctrl.screenshot() @@ -88,7 +88,7 @@ def _try_navigate_to_event( screen = ctrl.screenshot() # 确认仍在主页面 - if not PixelChecker.check_signature(screen, Sig.PAGE.ps).matched: + if not ImageChecker.template_exists(screen, Templates.MainPage.MAIN, confidence=0.85): _log.warning('[UI] 活动导航: 当前不在主页面基础态') return False diff --git a/autowsgr/ui/main_page/overlays.py b/autowsgr/ui/main_page/overlays.py index d1d7fdd5..8e69cd6d 100644 --- a/autowsgr/ui/main_page/overlays.py +++ b/autowsgr/ui/main_page/overlays.py @@ -19,8 +19,9 @@ import time from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger -from autowsgr.vision import PixelChecker +from autowsgr.vision import ImageChecker, PixelChecker from .constants import DismissCoord, OverlayKind, Sig @@ -51,22 +52,26 @@ def detect_overlay(screen: np.ndarray) -> OverlayKind | None: """检测截图中是否存在主页面浮层。 - 按优先级依次检测: NEWS → SIGN → BOOKING。 + 按优先级依次检测: NEWS → SIGN → BOOKING → USER_INFO。 Returns ------- OverlayKind | None 检测到的浮层类型,无浮层返回 ``None``。 """ - if PixelChecker.check_signature(screen, Sig.NEWS.ps).matched: + page = Templates.MainPage + if ImageChecker.template_exists(screen, page.NEWS, confidence=0.85): _log.debug('[UI] 检测到浮层: 新闻公告') return OverlayKind.NEWS - if PixelChecker.check_signature(screen, Sig.SIGN.ps).matched: + if ImageChecker.template_exists(screen, page.SIGN, confidence=0.85): _log.debug('[UI] 检测到浮层: 每日签到') return OverlayKind.SIGN - if PixelChecker.check_signature(screen, Sig.BOOKING.ps).matched: + if ImageChecker.template_exists(screen, page.BOOKING, confidence=0.85): _log.debug('[UI] 检测到浮层: 活动预约') return OverlayKind.BOOKING + if ImageChecker.template_exists(screen, page.USER_INFO, confidence=0.85): + _log.debug('[UI] 检测到浮层: 提督信息') + return OverlayKind.USER_INFO return None @@ -119,10 +124,8 @@ def dismiss_booking(ctrl: AndroidController) -> None: ctrl.click(*DismissCoord.BOOKING.xy) time.sleep(1.0) # 二次确认 — 若仍未返回主页面则再点一次 - from autowsgr.ui.main_page.constants import Sig as _Sig - screen = ctrl.screenshot() - if not PixelChecker.check_signature(screen, _Sig.PAGE.ps).matched: + if not ImageChecker.template_exists(screen, Templates.MainPage.MAIN, confidence=0.85): _log.warning('[UI] 活动预约: 首次关闭未生效,重试') ctrl.click(*DismissCoord.BOOKING.xy) time.sleep(1.0) @@ -133,6 +136,13 @@ def dismiss_booking(ctrl: AndroidController) -> None: # ───────────────────────────────────────────────────────────────────────────── +def dismiss_user_info(ctrl: AndroidController) -> None: + """关闭提督信息浮层 (全屏个人资料)。""" + _log.info('[UI] 提督信息: 关闭') + ctrl.click(*DismissCoord.USER_INFO_CLOSE.xy) + time.sleep(0.5) + + def dismiss_overlay(ctrl: AndroidController, overlay: OverlayKind) -> None: """消除指定类型的浮层。""" match overlay: @@ -142,5 +152,7 @@ def dismiss_overlay(ctrl: AndroidController, overlay: OverlayKind) -> None: dismiss_sign(ctrl) case OverlayKind.BOOKING: dismiss_booking(ctrl) + case OverlayKind.USER_INFO: + dismiss_user_info(ctrl) case _: raise ValueError(f'未知浮层类型: {overlay}') diff --git a/autowsgr/ui/map/base.py b/autowsgr/ui/map/base.py index 960a5b45..f67717d3 100644 --- a/autowsgr/ui/map/base.py +++ b/autowsgr/ui/map/base.py @@ -9,10 +9,14 @@ import time from typing import TYPE_CHECKING +import cv2 + from autowsgr.infra.logger import get_logger from autowsgr.types import PageName from autowsgr.ui.map.data import ( CLICK_BACK, + CHAPTER_OCR_ALLOWLIST, + CHAPTER_SLOT_ROIS, CLICK_EXPEDITION_SKIP, CLICK_PANEL, EXPEDITION_NOTIF_COLOR, @@ -20,19 +24,25 @@ EXPEDITION_TOLERANCE, PANEL_LIST, PANEL_TO_INDEX, + SIDEBAR_BRIGHTNESS_THRESHOLD, + SIDEBAR_SCAN_STEP, + SIDEBAR_SCAN_X, + SIDEBAR_SCAN_Y_RANGE, TITLE_CROP_REGION, + ChapterSlot, MapIdentity, MapPanel, + parse_chapter_label, parse_map_title, ) from autowsgr.ui.tabbed_page import ( TabbedPageType, + check_tabbed_page, get_active_tab_index, - identify_page_type, make_tab_checker, ) from autowsgr.ui.utils import NavigationError, click_and_wait_for_page -from autowsgr.vision import OCREngine, PixelChecker +from autowsgr.vision import OCREngine, PageMatch, PixelChecker if TYPE_CHECKING: @@ -70,9 +80,9 @@ def __init__( # ═══════════════════════════════════════════════════════════════════════ @staticmethod - def is_current_page(screen: np.ndarray) -> bool: - """判断截图是否为地图页面。""" - return identify_page_type(screen) == TabbedPageType.MAP + def is_current_page(screen: np.ndarray) -> PageMatch: + """判断截图是否为地图页面 (返回带覆盖度分数的 PageMatch)。""" + return check_tabbed_page(screen, TabbedPageType.MAP) # ═══════════════════════════════════════════════════════════════════════ # 状态查询 — 面板 @@ -103,9 +113,158 @@ def has_expedition_notification(screen: np.ndarray) -> bool: # ═══════════════════════════════════════════════════════════════════════ @staticmethod - def find_selected_chapter_y() -> float | None: - """扫描侧边栏,定位选中章节的 y 坐标。""" - return 0.556 + def find_selected_chapter_y(screen: np.ndarray) -> float | None: + """扫描侧边栏, 定位选中章节高亮条的 y 坐标。 + + 单级算法: 自适应阈值 + 连续段 (中心加权)。 + - 阈值 = max(峰值亮度×0.70, 均值+40), 放宽以兼容不同高亮主题; + - 邻接高亮点合并为「段」, 取最长段 (≥2 step 合格) 的亮度加权中心; + - 段覆盖 3%~60% 视为有效; 超出该范围直接返回 None。 + - **不再使用 Top-K 均值降级**: 之前 12% 亮点加权均值实际等价 + 于「侧边栏所有文字像素亮度中心」≈ 屏幕 0.45~0.5 固定区域, + 与真实选中章高亮位置完全无关, 导致 target_y 恒定跳错。 + """ + y_min, y_max = SIDEBAR_SCAN_Y_RANGE + step = SIDEBAR_SCAN_STEP + + # ── 第 1 步: 全量扫描亮度 ── + ys: list[float] = [] + brights: list[int] = [] + max_bright = 0 + sum_bright = 0 + + y = y_min + while y <= y_max: + c = PixelChecker.get_pixel(screen, SIDEBAR_SCAN_X, y) + brightness = c.r + c.g + c.b + ys.append(y) + brights.append(brightness) + if brightness > max_bright: + max_bright = brightness + sum_bright += brightness + y += step + + total_count = len(ys) + if total_count == 0: + _log.warning('[UI] 侧边栏扫描采样为空') + return None + + avg_bright = sum_bright / total_count + + # ── 第 2 步: 自适应阈值 (0.70*峰值 / 均值+40, 比旧版 avg+80 宽松 2x) ── + adaptive_threshold = max(int(max_bright * 0.70), int(avg_bright) + 40) + + # ── 第 3 步: 连续段 (≥2 step 合格) ── + segments: list[list[tuple[float, int]]] = [] # [(y, brightness)] + current: list[tuple[float, int]] = [] + prev_y: float | None = None + + for yy, br in zip(ys, brights): + if br >= adaptive_threshold: + if prev_y is not None and (yy - prev_y) <= step * 1.5: + current.append((yy, br)) + else: + if current: + segments.append(current) + current = [(yy, br)] + prev_y = yy + else: + if current: + segments.append(current) + current = [] + prev_y = None + if current: + segments.append(current) + + MIN_SEG_STEPS = 2 # ≥2 个连续采样点 (≈0.02 高度) 才认为是高亮条 + valid = [seg for seg in segments if len(seg) >= MIN_SEG_STEPS] + segs_info = sorted( + ((len(s), min(x[0] for x in s), max(x[0] for x in s)) for s in segments), + reverse=True, + )[:3] + + if not valid: + _log.debug( + '[UI] 侧边栏无有效高亮段 (segs={}, 最长段={}, max_br={} avg_br={} th={})', + len(segments), + segs_info[0] if segs_info else 'none', + max_bright, int(avg_bright), adaptive_threshold, + ) + return None + + # 取最长段, 以亮度加权求中心 (比纯平均更贴近高亮条峰值位置) + longest = max(valid, key=lambda s: len(s)) + cover = len(longest) / total_count + if not (0.03 <= cover <= 0.60): + _log.debug( + '[UI] 侧边栏高亮段覆盖异常 cover={:.0%} (segs={} valid={}), 放弃', + cover, len(segments), len(valid), + ) + return None + + total_w = sum(x[1] for x in longest) + if total_w <= 0: + return None + center = sum(x[0] * x[1] for x in longest) / total_w + y_start = longest[0][0] + y_end = longest[-1][0] + _log.debug( + '[UI] 侧边栏选中章 y={:.3f} (段长{}点 {:.3f}-{:.3f}, max_br={} avg_br={} th={} cover={:.0%})', + center, len(longest), y_start, y_end, + max_bright, int(avg_bright), adaptive_threshold, cover, + ) + return center + + def read_chapter_slots(self, screen: np.ndarray | None = None) -> tuple[ChapterSlot, ...]: + """Read the five fixed sortie chapter slots from one screenshot. + + The title OCR remains the map-state feedback. This method only answers + which chapter labels are currently visible in the sidebar and where + they can be clicked. + """ + if self._ocr is None: + raise RuntimeError('需要 OCR 引擎才能读取章节槽位') + if screen is None: + screen = self._ctrl.screenshot() + + slots: list[ChapterSlot] = [] + for index, roi in enumerate(CHAPTER_SLOT_ROIS): + crop = PixelChecker.crop(screen, *roi) + result = self._ocr.recognize_single(crop, allowlist=CHAPTER_OCR_ALLOWLIST) + chapter = parse_chapter_label(result.text) + + if chapter is None and result.text.strip() not in {'', '---'}: + gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY) + _, binary = cv2.threshold( + gray, + 0, + 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU, + ) + binary_rgb = cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB) + binary_result = self._ocr.recognize_single( + binary_rgb, + allowlist=CHAPTER_OCR_ALLOWLIST, + ) + binary_chapter = parse_chapter_label(binary_result.text) + if binary_chapter is not None: + result = binary_result + chapter = binary_chapter + + slots.append( + ChapterSlot( + index=index, + chapter=chapter, + text=result.text.strip(), + confidence=result.confidence, + ), + ) + + _log.debug( + '[UI] 章节槽位: {}', + [(slot.index, slot.chapter, slot.text, round(slot.confidence, 2)) for slot in slots], + ) + return tuple(slots) # ═══════════════════════════════════════════════════════════════════════ # 状态查询 — 地图 OCR diff --git a/autowsgr/ui/map/data.py b/autowsgr/ui/map/data.py index 3c89b8ff..756dcd1e 100644 --- a/autowsgr/ui/map/data.py +++ b/autowsgr/ui/map/data.py @@ -42,6 +42,16 @@ class MapIdentity: raw_text: str +@dataclass(frozen=True, slots=True) +class ChapterSlot: + """One fixed chapter slot in the sortie sidebar.""" + + index: int + chapter: int | None + text: str + confidence: float + + # ═══════════════════════════════════════════════════════════════════════════════ # 地图数据库 # ═══════════════════════════════════════════════════════════════════════════════ @@ -128,9 +138,6 @@ class MapIdentity: EXPEDITION_READY_COLOR = Color.of(253, 228, 66) """远征槽位就绪颜色 — 黄色 (表示该槽位远征已完成)。""" -EXPEDITION_IDLE_COLOR = Color.of(38, 147, 250) -"""远征槽位空闲颜色 — 蓝色 (表示该槽位无远征或进行中)。""" - DIFFICULTY_EASY_COLOR = Color.of(29, 139, 234) """难度按钮「简单」状态颜色 — 蓝色。""" @@ -180,12 +187,27 @@ class MapIdentity: SIDEBAR_BRIGHTNESS_THRESHOLD: int = 150 """选中章节的亮度阈值 (R+G+B)。""" -CHAPTER_SPACING: float = 0.12 -"""章节条目之间的 y 间距 (估算值)。""" - SIDEBAR_CLICK_X: float = 0.10 """侧边栏点击的 x 坐标。""" +CHAPTER_SLOT_CENTERS: tuple[float, ...] = (0.31, 0.43, 0.55, 0.67, 0.79) +"""出征章节侧边栏五个固定槽位的 y 中心。""" + +CHAPTER_SLOT_ROIS: tuple[tuple[float, float, float, float], ...] = ( + (0.055, 0.265, 0.17, 0.355), + (0.055, 0.385, 0.17, 0.475), + (0.055, 0.505, 0.17, 0.595), + (0.055, 0.625, 0.17, 0.715), + (0.055, 0.745, 0.17, 0.835), +) +"""五个章节标签 OCR 裁剪区域 (x1, y1, x2, y2)。""" + +CHAPTER_SLOT_CENTER_INDEX: int = 2 +"""固定槽位中代表当前选中章节的索引。""" + +CHAPTER_OCR_ALLOWLIST = '0123456789第章一二三四五六七八九十' +"""章节标签 OCR 允许字符。""" + # ═══════════════════════════════════════════════════════════════════════════════ # 点击坐标 @@ -194,8 +216,8 @@ class MapIdentity: CLICK_BACK: tuple[float, float] = (0.022, 0.058) """回退按钮 (◁)。""" -CHAPTER_NAV_DELAY: float = 0.5 -"""章节切换后等待动画的延迟 (秒)。""" +MAP_NAV_SETTLE_DELAY: float = 1.5 +"""地图章节/节点点击后等待界面稳定的延迟 (秒)。""" CHAPTER_NAV_MAX_ATTEMPTS: int = 20 """章节导航最大尝试次数。""" @@ -206,6 +228,61 @@ class MapIdentity: # ═══════════════════════════════════════════════════════════════════════════════ +_CHAPTER_NUMERALS: dict[str, int] = { + '一': 1, + '二': 2, + '三': 3, + '四': 4, + '五': 5, + '六': 6, + '七': 7, + '八': 8, + '九': 9, + '十': 10, +} + + +def parse_chapter_label(text: str) -> int | None: + """Parse a sidebar label such as ``第六章`` or ``---``.""" + compact = re.sub(r'\s+', '', text) + if not compact or re.fullmatch(r'[-—_~]+', compact): + return None + + match = re.fullmatch(r'第?([0-9]{1,2}|[一二三四五六七八九十百]+)章', compact) + if match is None: + return None + + token = match.group(1) + chapter = int(token) if token.isdigit() else _CHAPTER_NUMERALS.get(token) + if chapter is None or not 1 <= chapter <= TOTAL_CHAPTERS: + return None + return chapter + + +def choose_chapter_slot( + current: int, + target: int, + slots: tuple[ChapterSlot, ...], +) -> int | None: + """Choose a visible fixed slot for the next click, if one is valid.""" + for slot in slots: + if slot.chapter == target: + return slot.index + + delta = target - current + if delta == 0: + return None + + step = 2 if abs(delta) >= 2 else 1 + direction = 1 if delta > 0 else -1 + index = CHAPTER_SLOT_CENTER_INDEX + direction * step + if not 0 <= index < len(slots): + return None + + expected = current + direction * step + return index if slots[index].chapter == expected else None + + def parse_map_title(text: str) -> MapIdentity | None: """解析地图标题文本。 @@ -346,19 +423,6 @@ def parse_map_title(text: str) -> MapIdentity | None: } """出征面板中各地图节点的点击位置 (1-5, 从上到下)。""" -# ── 演习坐标 ── - -RIVAL_POSITIONS: list[tuple[float, float]] = [ - (0.800, 0.222), - (0.800, 0.444), - (0.800, 0.667), - (0.800, 0.889), -] -"""演习面板中 4 个对手位置的「挑战」按钮。""" - -CLICK_CHALLENGE: tuple[float, float] = (0.800, 0.500) -"""演习面板 — 通用挑战按钮。""" - # ── 演习 — 对手挑战状态检测 ── EXERCISE_CHALLENGE_COLOR = Color.of(33, 132, 226) diff --git a/autowsgr/ui/map/panels/sortie.py b/autowsgr/ui/map/panels/sortie.py index 260dadd6..8ed4d9ca 100644 --- a/autowsgr/ui/map/panels/sortie.py +++ b/autowsgr/ui/map/panels/sortie.py @@ -1,9 +1,15 @@ -"""出征面板 Mixin — 章节选择、地图节点导航与进入出征准备。""" +"""出征面板 Mixin — 章节选择、地图节点导航与进入出征准备。 + +与计数器相关的纯函数 (OCR 识别 LootShipCount) 已拆分至 +``sortie_counters.py``, 本文件只保留 UI 导航 / 面板交互逻辑。 +对外 API(campaign / panels ``__init__`` / e2e 等调用方)的导入 +路径保持不变: ``from autowsgr.ui.map.panels.sortie import X``, +本文件通过 ``from .sortie_counters import ...`` 重导出实现透明迁移。 +""" from __future__ import annotations import time -from dataclasses import dataclass from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger @@ -11,135 +17,36 @@ from autowsgr.ui.map.base import BaseMapPage from autowsgr.ui.map.data import ( CHAPTER_MAP_COUNTS, - CHAPTER_NAV_DELAY, CHAPTER_NAV_MAX_ATTEMPTS, - CHAPTER_SPACING, + CHAPTER_SLOT_CENTERS, + CHAPTER_SLOT_CENTER_INDEX, CLICK_ENTER_SORTIE, CLICK_MAP_NEXT, CLICK_MAP_PREV, - LOOT_COUNT_CROP, - SHIP_COUNT_CROP, + MAP_NAV_SETTLE_DELAY, SIDEBAR_CLICK_X, TOTAL_CHAPTERS, + ChapterSlot, MapPanel, + choose_chapter_slot, +) +# ── 计数器模块 (OCR 纯函数) 重导出 — 保持 sortie.py 对外符号不变 ── +from autowsgr.ui.map.panels.sortie_counters import ( # noqa: F401 重新导出 + LOOT_MAX, + SHIP_MAX, + LootShipCount, + recognize_loot_count, + recognize_ship_count, ) from autowsgr.ui.utils import click_and_wait_for_page -from autowsgr.vision import PixelChecker if TYPE_CHECKING: import numpy as np - from autowsgr.vision import OCREngine - _log = get_logger('ui') -LOOT_MAX = 50 -"""战利品 (胖次) 上限, 固定值。""" - -SHIP_MAX = 500 -"""舰船上限, 固定值。""" - - -# ── 数据类 ── - - -@dataclass(frozen=True, slots=True) -class LootShipCount: - """出征面板右上角的掉落计数。 - - Attributes - ---------- - loot: - 战利品 (胖次) 已获取数量, 识别失败时为 ``None``。 - loot_max: - 战利品上限, 固定 50。 - ship: - 舰船已获取数量, 识别失败时为 ``None``。 - ship_max: - 舰船上限, 固定 500。 - """ - - loot: int | None = None - loot_max: int = LOOT_MAX - ship: int | None = None - ship_max: int = SHIP_MAX - - -# ── 独立识别函数 ── - -_OCR_ALLOWLIST = '0123456789/|' -"""OCR 字符白名单。包含 ``/`` 和 ``|`` 使 OCR 正确识别斜线而非误读为 ``1``。""" - - -def _parse_numerator(text: str, max_val: int) -> int: - """从 ``"X/Y"`` 格式的 OCR 文本中提取分子 (``/`` 前的数字)。 - - - 优先按 ``/`` 或 ``|`` 分割取第一段。 - - 回退: 若无分隔符, 按已知分母剥离末尾后缀。 - """ - # 优先: 按 "/" 或 "|" 分割 - for sep in ('/', '|'): - if sep in text: - left = text.split(sep, 1)[0] - digits = ''.join(c for c in left if c.isdigit()) - if digits: - return int(digits) - raise ValueError(f'分子部分无数字: "{text}"') - - # 回退: OCR 偶尔把 "/" 识别为 "1", 导致纯数字串如 "17150"。 - # 已知分母为 max_val, 则后缀为 "1" + str(max_val)。 - digits = ''.join(c for c in text if c.isdigit()) - if not digits: - raise ValueError(f'文本中无数字: "{text}"') - suffix = '1' + str(max_val) - if digits.endswith(suffix) and len(digits) > len(suffix): - return int(digits[: -len(suffix)]) - # 无 "1" 前缀: 可能分母直接拼接 - denom_str = str(max_val) - if digits.endswith(denom_str) and len(digits) > len(denom_str): - return int(digits[: -len(denom_str)]) - return None - - -def recognize_loot_count(screen: np.ndarray, ocr: OCREngine) -> int | None: - """识别出征面板战利品 (胖次) 已获取数量。 - - OCR ``X/50`` 区域并提取 ``/`` 前的数字, 上限固定为 50。 - """ - img = PixelChecker.crop(screen, *LOOT_COUNT_CROP) - text = ocr.recognize_single(img, allowlist=_OCR_ALLOWLIST).text.strip() - if not text: - _log.warning('[UI] 战利品数量 OCR 无结果') - return None - count = _parse_numerator(text, LOOT_MAX) - if count > 50 and str(count).endswith('1'): - count = int(str(count)[:-1]) # 可能 OCR 把 "/50" 识别成 "150" - if count is not None: - _log.info('[UI] 战利品数量: {}/{}', count, LOOT_MAX) - else: - _log.warning("[UI] 战利品数量 OCR 解析失败: '{}'", text) - return count - - -def recognize_ship_count(screen: np.ndarray, ocr: OCREngine) -> int | None: - """识别出征面板舰船已获取数量。 - - OCR ``X/500`` 区域并提取 ``/`` 前的数字, 上限固定为 500。 - """ - img = PixelChecker.crop(screen, *SHIP_COUNT_CROP) - text = ocr.recognize_single(img, allowlist=_OCR_ALLOWLIST).text.strip() - if not text: - _log.warning('[UI] 舰船数量 OCR 无结果') - return None - count = _parse_numerator(text, SHIP_MAX) - if count is not None: - _log.info('[UI] 舰船数量: {}/{}', count, SHIP_MAX) - else: - _log.warning("[UI] 舰船数量 OCR 解析失败: '{}'", text) - return count - class SortiePanelMixin(BaseMapPage): """Mixin: 出征面板操作 — 选择章节 / 地图节点 / 进入出征准备。""" @@ -148,30 +55,15 @@ class SortiePanelMixin(BaseMapPage): # 章节 / 地图导航 # ═══════════════════════════════════════════════════════════════════════ - def click_chapter(self, num: int): - """点击侧边栏章节 - - Parameters - ---------- - num: - 跳转数量, 正数为向下跳转, 负数为向上跳转 - 允许输入[-3, 3] - """ - if not -3 <= num <= 3: - raise ValueError(f'跳转数量必须为 -3 到 3, 收到: {num}') - if num == 0: - return - sel_y = self.find_selected_chapter_y() - target_y = sel_y + num * CHAPTER_SPACING - _log.info('[UI] 地图页面 -> 跳转章节 {} (y={:.3f})', num, target_y) - self._ctrl.click(SIDEBAR_CLICK_X, target_y) - return + def click_chapter_slot(self, index: int) -> None: + """Click one of the five fixed chapter slots.""" + if not 0 <= index < len(CHAPTER_SLOT_CENTERS): + raise ValueError(f'章节槽位索引必须为 0-{len(CHAPTER_SLOT_CENTERS) - 1}, 收到: {index}') + self._ctrl.click(SIDEBAR_CLICK_X, CHAPTER_SLOT_CENTERS[index]) + _log.info('[UI] 地图页面→点击章节槽位 {} (y={:.3f})', index, CHAPTER_SLOT_CENTERS[index]) def navigate_to_chapter(self, target: int) -> int | None: - """导航到指定章节 (通过 OCR 识别当前位置并批量点击)。 - - 远距离章节切换时采用批量点击 + 充分等待的策略, - 避免单步验证导致动画过渡期的 OCR 抖动浪费尝试次数。 + """Navigate to a chapter through fixed sidebar slots and OCR feedback. Parameters ---------- @@ -183,78 +75,70 @@ def navigate_to_chapter(self, target: int) -> int | None: if self._ocr is None: raise RuntimeError('需要 OCR 引擎才能导航到指定章节') - def _read_chapter( - samples: int = 3, delay: float = 0.15 - ) -> tuple[int | None, np.ndarray | None, bool]: - chapters: list[int] = [] - last_screen: np.ndarray | None = None - - for i in range(samples): - screen = self._ctrl.screenshot() - last_screen = screen - info = self.recognize_map(screen, self._ocr) - if info is not None: - chapters.append(info.chapter) - if i < samples - 1: - time.sleep(delay) - - if not chapters: - return None, last_screen, False - - # 稳定策略:优先以"最后连续两次一致"为准,防止过渡态旧值占多数 - if len(chapters) >= 2 and chapters[-1] == chapters[-2]: - candidate = chapters[-1] - stable = True - elif len(chapters) == samples and len(set(chapters)) == 1: - candidate = chapters[0] - stable = True - else: - candidate = max(set(chapters), key=chapters.count) if chapters else None - stable = False - _log.warning('[UI] 章节导航: OCR 抖动 {},本轮不点击', chapters) - return candidate, last_screen, stable + def _read_slots() -> tuple[tuple[ChapterSlot, ...], int | None]: + screen = self._ctrl.screenshot() + slots = self.read_chapter_slots(screen) + title = self.recognize_map(screen, self._ocr) + current = slots[CHAPTER_SLOT_CENTER_INDEX].chapter + if current is None and title is not None: + current = title.chapter + if current is not None and title is not None and title.chapter != current: + _log.warning( + '[UI] 章节状态不一致: 侧边栏第{}章, 标题第{}章', + current, + title.chapter, + ) + return slots, current confirm_hits = 0 + previous_current: int | None = None + no_progress = 0 for attempt in range(CHAPTER_NAV_MAX_ATTEMPTS): - current, screen, stable = _read_chapter() + slots, current = _read_slots() + slot_state = [slot.chapter if slot.chapter is not None else slot.text or '---' for slot in slots] if current is None: - _log.warning('[UI] 章节导航: OCR 识别失败 (第 {} 次尝试)', attempt + 1) + _log.warning('[UI] 章节导航: 中间章节槽位识别失败 (第 {} 次尝试)', attempt + 1) + time.sleep(MAP_NAV_SETTLE_DELAY) + continue + + if previous_current == current: + no_progress += 1 + else: + no_progress = 0 + previous_current = current + if no_progress >= 3: + _log.warning('[UI] 章节导航: 连续无进展, 槽位={}', slot_state) return None if current == target: confirm_hits += 1 _log.info( - '[UI] 章节导航: 命中目标第 {} 章,二次确认 {}/2', + '[UI] 章节导航: 命中目标第 {} 章,确认 {}/2, 槽位={}', target, confirm_hits, + slot_state, ) if confirm_hits >= 2: _log.info('[UI] 章节导航: 已到达第 {} 章', target) return current - time.sleep(CHAPTER_NAV_DELAY) + time.sleep(MAP_NAV_SETTLE_DELAY) continue confirm_hits = 0 - _log.info( - '[UI] 章节导航: 当前第 {} 章 -> 目标第 {} 章', - current, - target, - ) - - if not stable or screen is None: - time.sleep(CHAPTER_NAV_DELAY) + index = choose_chapter_slot(current, target, slots) + if index is None: + _log.warning( + '[UI] 章节导航: 目标第 {} 章没有可用槽位 (当前={}, 槽位={})', + target, + current, + slot_state, + ) + time.sleep(MAP_NAV_SETTLE_DELAY) continue - delta = target - current - direction = 1 if delta > 0 else -1 - remaining = abs(delta) - while remaining > 0: - step = min(remaining, 3) * direction - self.click_chapter(step) - remaining -= abs(step) - _log.info(f'[UI] 章节导航: 跳转{step}章, 剩余{remaining}章') - time.sleep(CHAPTER_NAV_DELAY * abs(step)) + self.click_chapter_slot(index) + time.sleep(MAP_NAV_SETTLE_DELAY) _log.warning( '[UI] 章节导航: 超过最大尝试次数 ({}), 目标第 {} 章', @@ -264,23 +148,128 @@ def _read_chapter( return None def navigate_to_map(self, map_num: int | str) -> None: - """通过 OCR 识别当前地图编号并左右翻页至目标。""" + """在当前章节内, 翻页(←/→)至目标地图节点并 OCR 二次确认。 + + 与 :meth:`navigate_to_chapter` 采用同等强度的稳健策略: + 外层 ``CHAPTER_NAV_MAX_ATTEMPTS`` 次 attempt, + 每次 3-OCR 稳定读取当前 map_num, 每步单次 OCR 回检确认真的翻了, + 卡住 ≥2 次自动重启 attempt。 + """ map_num = int(map_num) - screen = self._ctrl.screenshot() - info = self.recognize_map(screen, self._ocr) - if info is not None: - current_map = info.map_num - if current_map != map_num: - delta = map_num - current_map - if delta > 0: - for _ in range(delta): - self._ctrl.click(*CLICK_MAP_NEXT) - time.sleep(0.3) + if self._ocr is None: + raise RuntimeError('需要 OCR 引擎才能导航到指定地图节点') + # 当前章最大地图数 (没有则退化为 99 让上层决定, 一般 enter_sortie 前置校验已挡住) + cur_max = 99 + try: + info_probe = self.recognize_map(self._ctrl.screenshot(), self._ocr) + if info_probe is not None: + cur_max = CHAPTER_MAP_COUNTS.get(int(info_probe.chapter), 99) + except Exception: # noqa: BLE001 - 探测失败不致命, 继续 + pass + if not 1 <= map_num <= cur_max: + raise ValueError( + f'地图编号 map_num={map_num} 超出范围 [1, {cur_max}] ' + '(若当前章识别失败请先调用 navigate_to_chapter 正确选章)', + ) + MAP_NAV_DELAY = MAP_NAV_SETTLE_DELAY + + def _read_map( + samples: int = 3, delay: float = 0.15 + ) -> tuple[int | None, bool]: + maps: list[int] = [] + for i in range(samples): + screen = self._ctrl.screenshot() + info = self.recognize_map(screen, self._ocr) + if info is not None: + maps.append(info.map_num) + if i < samples - 1: + time.sleep(delay) + if not maps: + return None, False + if len(maps) >= 2 and maps[-1] == maps[-2]: + return maps[-1], True + if len(maps) == samples and len(set(maps)) == 1: + return maps[0], True + candidate = max(set(maps), key=maps.count) + _log.warning('[UI] 地图节点导航: OCR 抖动 {}, 本轮不点击'.format(maps)) + return candidate, False + + def _quick_map() -> int | None: + screen = self._ctrl.screenshot() + info = self.recognize_map(screen, self._ocr) + return info.map_num if info is not None else None + + confirm = 0 + for attempt in range(CHAPTER_NAV_MAX_ATTEMPTS): + current, stable = _read_map() + if current is None: + _log.warning('[UI] 地图节点导航: OCR 识别失败 (attempt %d/%d)', attempt + 1, CHAPTER_NAV_MAX_ATTEMPTS) + continue + + if current == map_num: + confirm += 1 + _log.info('[UI] 地图节点导航: 命中目标 %d-%d 确认 %d/2', current, map_num, confirm) + if confirm >= 2: + _log.info('[UI] 地图节点导航: 已到达当前章第 %d 节 (地图编号 %d)', map_num, map_num) + return + time.sleep(MAP_NAV_DELAY) + continue + + confirm = 0 + if not stable: + time.sleep(MAP_NAV_DELAY) + continue + + delta = map_num - current + remaining = abs(map_num - current) + stuck = 0 + misses = 0 + steps = 0 + MAX_STUCK = 3 + MAX_MISSES = 6 + + _log.info( + '[UI] 地图节点导航: 当前 %d -> 目标 %d (delta=%+d)', + current, map_num, delta, + ) + + while remaining > 0 and stuck < MAX_STUCK and misses < MAX_MISSES: + direction = 1 if map_num > current else -1 + if direction == 1: + self._ctrl.click(*CLICK_MAP_NEXT) + _log.info('[UI] 地图节点导航: → 下一节 (remaining %d, steps %d)', remaining, steps + 1) + else: + self._ctrl.click(*CLICK_MAP_PREV) + _log.info('[UI] 地图节点导航: ← 上一节 (remaining %d, steps %d)', remaining, steps + 1) + steps += 1 + remaining -= 1 + time.sleep(MAP_NAV_DELAY + 0.20) # 翻页动画比章节切换长 + + qc = _quick_map() + if qc is None: + misses += 1 + continue + misses = 0 + if (direction == 1 and qc <= current) or (direction == -1 and qc >= current): + if qc == current and stuck == 0: + stuck = 1 + else: + stuck += 1 + _log.warning( + '[UI] 地图节点导航: 翻页后仍是第%d节 (cur=%d, stuck=%d/%d)', + qc, current, stuck, MAX_STUCK, + ) else: - for _ in range(-delta): - self._ctrl.click(*CLICK_MAP_PREV) - time.sleep(0.3) - time.sleep(0.5) + stuck = 0 + current = qc + remaining = abs(map_num - current) + if stuck >= MAX_STUCK or misses >= MAX_MISSES: + _log.warning('[UI] 地图节点导航: 卡住/识别失败超限 (stuck=%d misses=%d), 重启 attempt', stuck, misses) + continue + + raise RuntimeError( + f'地图节点导航超过最大尝试次数 {CHAPTER_NAV_MAX_ATTEMPTS}, 目标节 {map_num} 未到达', + ) # ═══════════════════════════════════════════════════════════════════════ # 掉落数量读取 @@ -289,6 +278,8 @@ def navigate_to_map(self, map_num: int | str) -> None: def get_loot_and_ship_count( self, screen: np.ndarray | None = None, + *, + read_loot: bool = True, ) -> LootShipCount: """读取出征面板右上角的已获取舰船/战利品数量。 @@ -298,13 +289,17 @@ def get_loot_and_ship_count( ---------- screen: 截图,为 ``None`` 时自动截取。 + read_loot: + 是否识别战利品 (胖次) 数量。仅在 YAML 开启 ``stop_max_loot`` + (战利品检查) 时为 True; 无战利品活动时置 False 跳过该区域 OCR, + 避免对不存在的计数器进行无效识别。 """ if self._ocr is None: raise RuntimeError('需要 OCR 引擎才能读取掉落数量') if screen is None: screen = self._ctrl.screenshot() - loot = recognize_loot_count(screen, self._ocr) + loot = recognize_loot_count(screen, self._ocr) if read_loot else None ship = recognize_ship_count(screen, self._ocr) return LootShipCount(loot=loot, ship=ship) @@ -336,7 +331,7 @@ def enter_sortie(self, chapter: int | str, map_num: int | str) -> None: # 1. 确保在出征面板 self.ensure_panel(MapPanel.SORTIE) - time.sleep(0.5) + time.sleep(MAP_NAV_SETTLE_DELAY) # 2. 导航到指定章节 if isinstance(chapter, int): diff --git a/autowsgr/ui/map/panels/sortie_counters.py b/autowsgr/ui/map/panels/sortie_counters.py new file mode 100644 index 00000000..7b57e97a --- /dev/null +++ b/autowsgr/ui/map/panels/sortie_counters.py @@ -0,0 +1,128 @@ +"""出征面板计数器识别 — 战利品/舰船数量 OCR 与数据类。 + +拆分自 sortie.py 中「纯 OCR + 数据结构」部分,独立文件便于 +复用 (campaign.py) 与测试,减少 SortiePanelMixin 体积。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from autowsgr.infra.logger import get_logger +from autowsgr.ui.map.data import LOOT_COUNT_CROP, SHIP_COUNT_CROP +from autowsgr.vision import PixelChecker + + +if TYPE_CHECKING: + import numpy as np + + from autowsgr.vision import OCREngine + + +_log = get_logger('ui') + +LOOT_MAX = 50 +"""战利品 (胖次) 上限, 固定值。""" + +SHIP_MAX = 500 +"""舰船上限, 固定值。""" + + +# ── 数据类 ── + + +@dataclass(frozen=True, slots=True) +class LootShipCount: + """出征面板右上角的掉落计数。 + + Attributes + ---------- + loot: + 战利品 (胖次) 已获取数量, 识别失败时为 ``None``。 + loot_max: + 战利品上限, 固定 50。 + ship: + 舰船已获取数量, 识别失败时为 ``None``。 + ship_max: + 舰船上限, 固定 500。 + """ + + loot: int | None = None + loot_max: int = LOOT_MAX + ship: int | None = None + ship_max: int = SHIP_MAX + + +# ── 独立识别函数 ── + +_OCR_ALLOWLIST = '0123456789/|' +"""OCR 字符白名单。包含 ``/`` 和 ``|`` 使 OCR 正确识别斜线而非误读为 ``1``。""" + + +def _parse_numerator(text: str, max_val: int) -> int: + """从 ``"X/Y"`` 格式的 OCR 文本中提取分子 (``/`` 前的数字)。 + + - 优先按 ``/`` 或 ``|`` 分割取第一段。 + - 回退: 若无分隔符, 按已知分母剥离末尾后缀。 + """ + # 优先: 按 "/" 或 "|" 分割 + for sep in ('/', '|'): + if sep in text: + left = text.split(sep, 1)[0] + digits = ''.join(c for c in left if c.isdigit()) + if digits: + return int(digits) + raise ValueError(f'分子部分无数字: "{text}"') + + # 回退: OCR 偶尔把 "/" 识别为 "1", 导致纯数字串如 "17150"。 + # 已知分母为 max_val, 则后缀为 "1" + str(max_val)。 + digits = ''.join(c for c in text if c.isdigit()) + if not digits: + raise ValueError(f'文本中无数字: "{text}"') + suffix = '1' + str(max_val) + if digits.endswith(suffix) and len(digits) > len(suffix): + return int(digits[: -len(suffix)]) + # 无 "1" 前缀: 可能分母直接拼接 + denom_str = str(max_val) + if digits.endswith(denom_str) and len(digits) > len(denom_str): + return int(digits[: -len(denom_str)]) + return None + + +def recognize_loot_count(screen: np.ndarray, ocr: OCREngine) -> int | None: + """识别出征面板战利品 (胖次) 已获取数量。 + + OCR ``X/50`` 区域并提取 ``/`` 前的数字, 上限固定为 50。 + """ + img = PixelChecker.crop(screen, *LOOT_COUNT_CROP) + text = ocr.recognize_single(img, allowlist=_OCR_ALLOWLIST).text.strip() + if not text: + _log.warning('[UI] 战利品数量 OCR 无结果') + return None + count = _parse_numerator(text, LOOT_MAX) + if count > 50 and str(count).endswith('1'): + count = int(str(count)[:-1]) # 可能 OCR 把 "/50" 识别成 "150" + if count is not None: + _log.info('[UI] 战利品数量: {}/{}', count, LOOT_MAX) + else: + _log.warning("[UI] 战利品数量 OCR 解析失败: '{}'", text) + return count + + +def recognize_ship_count(screen: np.ndarray, ocr: OCREngine) -> int | None: + """识别出征面板舰船已获取数量。 + + OCR ``X/500`` 区域并提取 ``/`` 前的数字, 上限固定为 500。 + """ + img = PixelChecker.crop(screen, *SHIP_COUNT_CROP) + text = ocr.recognize_single(img, allowlist=_OCR_ALLOWLIST).text.strip() + if not text: + _log.warning('[UI] 舰船数量 OCR 无结果') + return None + count = _parse_numerator(text, SHIP_MAX) + if count is not None: + _log.info('[UI] 舰船数量: {}/{}', count, SHIP_MAX) + else: + _log.warning("[UI] 舰船数量 OCR 解析失败: '{}'", text) + return count diff --git a/autowsgr/ui/mission_page/page.py b/autowsgr/ui/mission_page/page.py index e9f7aacb..de604957 100644 --- a/autowsgr/ui/mission_page/page.py +++ b/autowsgr/ui/mission_page/page.py @@ -32,9 +32,9 @@ MissionPanel, ) from autowsgr.ui.mission_page.recognition import find_button_rows, recognize_row -from autowsgr.ui.tabbed_page import TabbedPageType, identify_page_type +from autowsgr.ui.tabbed_page import TabbedPageType, check_tabbed_page from autowsgr.ui.utils import click_and_wait_for_page -from autowsgr.vision import ImageChecker +from autowsgr.vision import ImageChecker, PageMatch if TYPE_CHECKING: @@ -63,9 +63,9 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: - """判断截图是否为任务页面。""" - return identify_page_type(screen) == TabbedPageType.MISSION + def is_current_page(screen: np.ndarray) -> PageMatch: + """判断截图是否为任务页面 (返回带覆盖度分数的 PageMatch)。""" + return check_tabbed_page(screen, TabbedPageType.MISSION) # ── 面板切换 ────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/navigation.py b/autowsgr/ui/navigation.py index 610c35d3..a219d183 100644 --- a/autowsgr/ui/navigation.py +++ b/autowsgr/ui/navigation.py @@ -114,17 +114,6 @@ def _map_to_decisive(ctx: GameContext) -> None: MapPage(ctx).enter_decisive() -def _battle_prep_to_map(ctx: GameContext) -> None: - from autowsgr.ui.battle.preparation import BattlePreparationPage - - BattlePreparationPage(ctx).go_back() - - -def _choose_ship_to_battle_prep(ctx: GameContext) -> None: - # 选船页面的返回按钮和出征准备页面位置一致 - ctx.ctrl.click(0.022, 0.058) - - def _backyard_to_bath(ctx: GameContext) -> None: from autowsgr.ui.backyard_page import BackyardPage, BackyardTarget @@ -219,11 +208,8 @@ def _event_to_main(ctx: GameContext) -> None: NavEdge(PageName.SIDEBAR, PageName.MAIN, _sidebar_to_main, '侧边栏 → 主页面'), # ── 地图 → 子页面 ── NavEdge(PageName.MAP, PageName.DECISIVE_BATTLE, _map_to_decisive, '地图 → 决战'), - # ── 出征准备 ↔ 选船 ── - NavEdge(PageName.BATTLE_PREP, PageName.MAP, _battle_prep_to_map, '出征准备 → 地图'), - NavEdge( - PageName.CHOOSE_SHIP, PageName.BATTLE_PREP, _choose_ship_to_battle_prep, '选船 → 出征准备' - ), + # BATTLE_PREP / CHOOSE_SHIP 不入图: 来源依赖战斗模式, 由 ops.navigate + # 的显式路线处理。活动战返回活动地图后再由 ops.navigate 进入澡堂。 # ── 后院 ↔ 子页面 ── NavEdge(PageName.BACKYARD, PageName.BATH, _backyard_to_bath, '后院 → 浴室'), NavEdge(PageName.BACKYARD, PageName.CANTEEN, _backyard_to_canteen, '后院 → 食堂'), @@ -284,3 +270,22 @@ def find_path(source: str, target: str) -> list[NavEdge] | None: queue.append((edge.target, new_path)) return None + + +def neighbors(page: str) -> set[str]: + """返回 *page* 经一条导航边可达的所有页面名(一步后继集合)。 + + 供页面识别候选集约束使用:导航时从 *page* 执行任意动作后, + 结果页面必在此集合(或 *page* 自身)内。 + + Parameters + ---------- + page: + 页面名称 (:class:`PageName` 或等价字符串)。 + + Returns + ------- + set[str] + 一步可达的页面名集合;无出边时返回空集。 + """ + return {str(e.target.value) for e in _adjacency.get(page, [])} diff --git a/autowsgr/ui/page.py b/autowsgr/ui/page.py index 6af6fa18..de3016fc 100644 --- a/autowsgr/ui/page.py +++ b/autowsgr/ui/page.py @@ -25,6 +25,7 @@ wait_for_page, wait_leave_page, ) +from autowsgr.vision.page_match import PageMatch if TYPE_CHECKING: @@ -39,39 +40,151 @@ # 页面注册中心 # --------------------------------------------------------------------------- -_PAGE_REGISTRY: dict[str, Callable[[np.ndarray], bool]] = {} - - -def register_page(name: str, checker: Callable[[np.ndarray], bool]) -> None: - """注册页面识别函数。""" +_PAGE_REGISTRY: dict[str, Callable[[np.ndarray], PageMatch | bool]] = {} + +_OVERLAY_PAGES: set[str] = set() +"""覆盖型页面名集合 (抽屉/浮层)。 + +这类页面**不遮挡底页的识别元素** (如侧边栏是左侧抽屉, 主页面签名元素在 +右侧): 打开时底页签名仍会命中, 且分数往往更高 (主页面 0.988 vs 侧边栏 +~0.86)。纯分数排序会把"侧边栏开着"误判为纯主页面, 导致导航在"已到达 +侧边栏"与"当前是主页面"之间震荡 — 反复点击切换按钮把侧边栏开了又关, +等待超时 (实机 2026-08-16: 船坞满自动解装的 MAIN→SIDEBAR 死循环)。""" + + +def register_page( + name: str, + checker: Callable[[np.ndarray], PageMatch | bool], + *, + overlay: bool = False, +) -> None: + """注册页面识别函数。 + + checker 宜返回 :class:`~autowsgr.vision.page_match.PageMatch`(携带 score, + 供候选集排序);兼容旧式返回 ``bool`` 的识别器(自动归一化:True→1.0, False→0.0)。 + + Parameters + ---------- + name: + 页面名 (PageName 或纯 str)。 + checker: + 页面识别函数。 + overlay: + 是否为覆盖型页面 (抽屉/浮层, 见 :data:`_OVERLAY_PAGES`)。 + 覆盖型页面命中时优先于底页返回 (z-order 优先于分数)。 + """ # Python 3.13+ 中 StrEnum 的 str()/format() 返回 'ClassName.MEMBER' 而非值, # 显式提取 .value 确保 key 始终为纯 str,避免日志和比较中出现意外格式。 key: str = name.value if hasattr(name, 'value') else name if key in _PAGE_REGISTRY: _log.warning("[UI] 页面 '{}' 已注册,将覆盖", key) _PAGE_REGISTRY[key] = checker + if overlay: + _OVERLAY_PAGES.add(key) + else: + _OVERLAY_PAGES.discard(key) # _log.debug("[UI] 注册页面: {}", key) -def get_current_page(screen: np.ndarray) -> str | None: - """识别截图对应的页面名称,无匹配返回 ``None``。""" - failed_checkers: list[str] = [] - for name, checker in _PAGE_REGISTRY.items(): +def _normalize_match(name: str, result: object) -> PageMatch: + """把识别器返回值归一化为 :class:`PageMatch`。 + + 兼容三种返回: + + - :class:`PageMatch` —— 直接采用(以注册名覆盖 ``result.name``,防漂移) + - ``bool`` —— 旧式布尔识别器,True → score=1.0, False → 0.0 + - 其他(如 :class:`~autowsgr.vision.PixelMatchResult`)—— + 取 ``.matched`` 与 ``.score``/``.ratio``/``.confidence`` 之一 + """ + if isinstance(result, PageMatch): + return ( + result + if result.name == name + else PageMatch( + name=name, + matched=result.matched, + score=result.score, + ) + ) + if isinstance(result, bool): + return PageMatch(name=name, matched=result, score=1.0 if result else 0.0) + matched = bool(getattr(result, 'matched', False)) + raw = getattr(result, 'score', None) + if raw is None: + raw = getattr(result, 'ratio', None) + if raw is None: + raw = getattr(result, 'confidence', None) + score = float(raw) if raw is not None else (1.0 if matched else 0.0) + return PageMatch(name=name, matched=matched, score=score) + + +def get_current_page( + screen: np.ndarray, + candidates: set[str] | None = None, +) -> str | None: + """识别截图对应的页面名称,无匹配返回 ``None``。 + + 覆盖型页面 (overlay) 命中时优先于底页返回 — 用户可见/可操作的是覆盖层, + 底页签名同时命中属于预期 (不遮挡)。非覆盖命中集内按 ``score`` 降序取 + 最高分(替代旧的"首次匹配即胜"); 分数相同者保持注册(候选)顺序。每个 + 识别器返回 :class:`~autowsgr.vision.page_match.PageMatch`,score 归一自 + 像素 ratio / 模板 confidence / tabbed 覆盖度。 + + Parameters + ---------- + screen: + 截图 (HxWx3, RGB)。 + candidates: + 候选页面名集合。给出时只评估候选页 —— 导航时可利用"当前页 + 可达页" + 上下文约束,大幅降低不特异签名(如决战页深色背景点)误命中无关页的风险。 + 为 ``None`` 时评估全部注册页。 + """ + # 按注册顺序遍历候选(而非候选 set 的任意顺序):同分时稳定排序保持注册顺序, + # 让旧的"注册顺序优先级"(如 EVENT_MAP 排在 DECISIVE_BATTLE 前)自然融入排序语义。 + reg_order: list[str] = list(_PAGE_REGISTRY.keys()) + if candidates is not None: + names = [n for n in reg_order if n in candidates] + else: + names = reg_order + hits: list[PageMatch] = [] + failed: list[str] = [] + for name in names: + checker = _PAGE_REGISTRY.get(name) + if checker is None: + continue try: - if checker(screen): - _log.debug('[UI] 当前页面: {}', name) - return name + pm = _normalize_match(name, checker(screen)) except Exception: _log.opt(exception=True).warning("[UI] 页面 '{}' 识别器异常", name) - failed_checkers.append(name) - if failed_checkers: + failed.append(name) + continue + if pm.matched: + hits.append(pm) + + if hits: + # 覆盖型优先 (z-order): 侧边栏开着时主页面也命中且分更高, 但当前页是侧边栏 + overlay_hits = [m for m in hits if m.name in _OVERLAY_PAGES] + pool = overlay_hits or hits + # 稳定排序:分数降序,同分保持候选顺序 + pool.sort(key=lambda m: m.score, reverse=True) + best = pool[0] + _log.debug( + '[UI] 当前页面: {} (score={:.3f}, 候选 {} / 命中 {})', + best.name, + best.score, + len(names), + len(hits), + ) + return best.name + + if failed: _log.warning( - '[UI] 无匹配页面,且以下识别器抛异常: {} (共 {} 个注册页面)', - failed_checkers, - len(_PAGE_REGISTRY), + '[UI] 无匹配页面,且以下识别器抛异常: {} (评估 {} 个候选)', + failed, + len(names), ) else: - _log.debug('[UI] 当前页面: 无匹配 (共 {} 个注册页面)', len(_PAGE_REGISTRY)) + _log.debug('[UI] 当前页面: 无匹配 (评估 {} 个候选)', len(names)) return None diff --git a/autowsgr/ui/sidebar_page.py b/autowsgr/ui/sidebar_page.py index aa24c80d..5d596e49 100644 --- a/autowsgr/ui/sidebar_page.py +++ b/autowsgr/ui/sidebar_page.py @@ -25,13 +25,11 @@ import time from typing import TYPE_CHECKING +from autowsgr.image_resources import Templates from autowsgr.infra.logger import get_logger from autowsgr.types import PageName from autowsgr.ui.utils import click_and_wait_for_page, wait_for_page -from autowsgr.vision import ( - Color, - PixelChecker, -) +from autowsgr.vision import ImageChecker, PageMatch if TYPE_CHECKING: @@ -61,25 +59,8 @@ class SidebarTarget(enum.Enum): # 页面识别签名 # ═══════════════════════════════════════════════════════════════════════════════ -MENU_PROBES: list[tuple[float, float]] = [ - (0.0417, 0.0806), # 商城 - (0.0422, 0.2102), # 活动 - (0.0453, 0.3463), # 建造 - (0.0406, 0.4676), # 强化 - (0.0396, 0.6028), # 图鉴 - (0.0432, 0.7231), # 好友 -] -"""侧边栏左侧 6 个菜单探测点 (来自 sig.py)。 - -每个点在未选中时为深灰 ≈ (57, 57, 57),选中时为亮蓝 ≈ (0, 160, 232)。 -""" - -_MENU_GRAY = Color.of(57, 57, 57) -"""菜单项未选中颜色 (深灰)。""" -_MENU_SELECTED = Color.of(0, 160, 232) -"""菜单项选中颜色 (亮蓝)。""" -_MENU_TOLERANCE = 30.0 -"""菜单项颜色匹配容差。""" +# 页面级识别已改用 Templates.Page.SIDEBAR 模板匹配 (迁移自 classic options_page), +# 详见 :meth:`SidebarPage.is_current_page`。 # ═══════════════════════════════════════════════════════════════════════════════ @@ -145,28 +126,23 @@ def __init__(self, ctx: GameContext) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为侧边栏页面。 - 检测逻辑: - 1. 6 个左侧菜单探测点每个都必须匹配 **灰色** 或 **蓝色高亮**。 - 2. 蓝色高亮的数量为 0 或 1 (无选中 / 单选中)。 + 用侧边栏独有的大区域模板 (classic ``options_page``) 匹配。 + 正样本置信度约 0.86 (大模板含背景, 易随主题波动), 故阈值放宽到 0.8; + 其他页最高仅 0.32, 区分度充足。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - blue_count = 0 - for x, y in MENU_PROBES: - pixel = PixelChecker.get_pixel(screen, x, y) - if pixel.near(_MENU_SELECTED, _MENU_TOLERANCE): - blue_count += 1 - elif pixel.near(_MENU_GRAY, _MENU_TOLERANCE): - pass # 灰色 — 正常 - else: - return False # 既不灰也不蓝 → 不是侧边栏 - return blue_count <= 1 + result = ImageChecker.find_template(screen, Templates.Page.SIDEBAR, confidence=0.8) + name = PageName.SIDEBAR.value + if result is None: + return PageMatch(name=name, matched=False, score=0.0) + return PageMatch(name=name, matched=True, score=result.confidence) # ── 导航 ────────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/stack.py b/autowsgr/ui/stack.py new file mode 100644 index 00000000..8724f70a --- /dev/null +++ b/autowsgr/ui/stack.py @@ -0,0 +1,282 @@ +"""UI 导航栈 — 页面来路追踪。 + +栈有两个消费者: + +1. **识别候选剪枝** — :meth:`UIStack.candidates` 把全注册表盲扫描收缩到 + "当前页 + 其邻域 + 父页 + 目标页及其邻域", 降低不特异签名误命中风险。 + 相比纯邻域机制额外纳入 ``{parent}``, 覆盖"识别发生在回退动作之后"的 + 时序, 以及 CHOOSE_SHIP 这类导航图中无入边的叶子页 (来路只存在于栈中)。 +2. **go_back 期望父页** — 回退前从栈 ``pop()`` 得到期望落点, 供正向验证。 + +导航循环每步识别后调用 :meth:`UIStack.observe` 对账, 实际页面与栈预测 +不符时置 ``drifted``, 由 :meth:`UIStack.resync` 全量识别重建。 +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from autowsgr.infra.logger import get_logger +from autowsgr.ui.navigation import neighbors + + +if TYPE_CHECKING: + import numpy as np + + from autowsgr.types import PageName + + +_log = get_logger('ui') + +OVERLAY_CANDIDATES: set[str] = set() +"""任意导航步后都可能叠加出现、固定纳入识别候选的页面名集合。 + +当前为空 —— 主页面浮层 (新闻/签到/预约/提督信息) 已被 MAIN 识别器吸收, +网络错误等全局弹窗页留待后续填充。 +""" + + +def _page_name(page: str | PageName) -> str: + """归一化页面名 (兼容 :class:`PageName` 与纯 ``str``)。""" + # Python 3.13+ 中 StrEnum 的 str()/format() 返回 'ClassName.MEMBER', + # 显式取 .value 确保始终为纯 str。 + return page.value if hasattr(page, 'value') else page + + +@dataclass(slots=True) +class _StackFrame: + """栈内单帧:页面名 + 留存截图 (可选)。""" + + page: str + screen: np.ndarray | None = None + + +class UIStack: + """UI 导航栈:页面来路追踪。 + + 不持有 :class:`~autowsgr.context.GameContext` (避免循环依赖), + 识别对账所需的页面名由调用方传入。线程锁为防御性持有 + (ops 层单线程是既有假设)。 + + Parameters + ---------- + max_depth: + 栈最大深度, 超出时丢弃最老帧 (保底保留根帧)。 + keep_frames: + 留存截图的帧数上限 —— 仅最近 N 帧保留 ``screen``, + 更早帧只留页面名 (内存 ~4MB 上限)。留存帧目前只存不消费, + 为后续浮层比对预留参照帧。 + """ + + def __init__(self, *, max_depth: int = 8, keep_frames: int = 2) -> None: + self._frames: list[_StackFrame] = [] + self._max_depth = max_depth + self._keep_frames = keep_frames + self._drifted = False + self._lock = threading.Lock() + + # ── 栈操作 ────────────────────────────────────────────────────────── + + def push(self, page: str | PageName, *, screen: np.ndarray | None = None) -> None: + """进入新页面 (声明意图; 下一轮识别的 ``observe`` 会纠正)。""" + name = _page_name(page) + with self._lock: + if self._frames and self._frames[-1].page == name: + # 同页重入 (如浮层开合) 刷新留存帧, 不叠重复帧 + self._frames[-1].screen = screen + else: + self._frames.append(_StackFrame(name, screen)) + self._trim() + + def pop(self) -> str | None: + """弹出栈顶并返回其页面名; 空栈返回 ``None``。 + + go_back 的期望父页来源: pop 后的新栈顶即期望落点。 + """ + with self._lock: + if not self._frames: + return None + return self._frames.pop().page + + def replace(self, page: str | PageName, *, screen: np.ndarray | None = None) -> None: + """替换栈顶 (同层 tab 切换 / 人工识别校正), 并清除漂移标记。""" + name = _page_name(page) + with self._lock: + self._frames[-1:] = [_StackFrame(name, screen)] + self._drifted = False + self._trim() + + def reset(self, page: str | PageName, *, screen: np.ndarray | None = None) -> None: + """清空栈并以 *page* 重建单帧根 (:meth:`resync` 使用)。""" + name = _page_name(page) + with self._lock: + self._frames = [_StackFrame(name, screen)] + self._drifted = False + + def _trim(self) -> None: + """深度裁剪 + 留存帧淘汰。调用方须持锁。""" + if len(self._frames) > self._max_depth: + del self._frames[: len(self._frames) - self._max_depth] + # 仅保留最近 keep_frames 帧的留存截图 + for i, frame in enumerate(self._frames): + if len(self._frames) - i > self._keep_frames: + frame.screen = None + + # ── 查询 ──────────────────────────────────────────────────────────── + + @property + def current(self) -> str | None: + """栈顶页面名; 空栈返回 ``None``。""" + with self._lock: + return self._frames[-1].page if self._frames else None + + @property + def parent(self) -> str | None: + """栈顶的上一页 (来路); 栈深 < 2 返回 ``None``。""" + with self._lock: + return self._frames[-2].page if len(self._frames) >= 2 else None + + @property + def depth(self) -> int: + """当前栈深。""" + with self._lock: + return len(self._frames) + + def pages(self) -> tuple[str, ...]: + """栈快照 (根 → 栈顶), 调试 / 日志用。""" + with self._lock: + return tuple(f.page for f in self._frames) + + @property + def drifted(self) -> bool: + """实际页面是否已脱离栈预测范围 (待 :meth:`resync` 重建)。""" + return self._drifted + + # ── 识别候选 ──────────────────────────────────────────────────────── + + def candidates(self, target: str | PageName | None = None) -> set[str]: + """计算下一轮页面识别的候选集:: + + {current} + neighbors(current) + {parent} + + {target} + neighbors(target) + OVERLAY_CANDIDATES + + 空栈且无 *target* 时返回空集 —— 调用方应视为 ``None`` (全量识别)。 + + Parameters + ---------- + target: + 导航目标页 (可选)。 + """ + with self._lock: + current = self._frames[-1].page if self._frames else None + parent = self._frames[-2].page if len(self._frames) >= 2 else None + + result: set[str] = set() + if current is not None: + result.add(current) + result |= neighbors(current) + if parent is not None: + result.add(parent) + if target is not None: + name = _page_name(target) + result.add(name) + result |= neighbors(name) + result |= OVERLAY_CANDIDATES + return result + + # ── 识别对账 ──────────────────────────────────────────────────────── + + def observe(self, identified: str | PageName, *, screen: np.ndarray | None = None) -> str: + """用识别结果对账栈, 返回对账后的栈顶页面名。 + + 四个分支 (按序判定):: + + 栈空 → push 为根 + == current → 不动, 刷新留存帧 + == parent → pop (自然回退) + ∈ neighbors(current) → push (前进) + 其他 → drifted, 不改栈 + + ``parent`` 判定先于 ``neighbors``: 双向边 (如 MAP↔MAIN) 上 + "识别为父页"应理解为回退而非前进, pop 才能正确回收来路。 + + Parameters + ---------- + identified: + 全量 / 候选识别得到的实际页面名。 + screen: + 触发本次识别的截图 (存为留存帧, 可选)。 + """ + name = _page_name(identified) + with self._lock: + if not self._frames: + self._frames = [_StackFrame(name, screen)] + return name + + current = self._frames[-1].page + parent = self._frames[-2].page if len(self._frames) >= 2 else None + + if name == current: + self._frames[-1].screen = screen + return current + + if parent is not None and name == parent: + self._frames.pop() + self._frames[-1].screen = screen + return name + + if name in neighbors(current): + self._frames.append(_StackFrame(name, screen)) + self._trim() + return name + + self._drifted = True + _log.warning( + "[UIStack] 漂移: 识别为 '{}', 栈预测 '{}' (栈: {})", + name, + current, + ' → '.join(f.page for f in self._frames), + ) + return current + + # ── 重建 / 留存帧 ─────────────────────────────────────────────────── + + def resync(self, screen: np.ndarray) -> str | None: + """全量识别 *screen* 并据识别结果重建栈 (单帧根)。 + + 重建不猜祖先链 —— 来路信息已不可靠, 由后续导航重新积累。 + 识别失败时清空栈并返回 ``None``。 + + Parameters + ---------- + screen: + 当前截图。 + + Returns + ------- + str | None + 识别并重建后的根页面名; 识别失败返回 ``None``。 + """ + from autowsgr.ui.page import get_current_page + + identified = get_current_page(screen) + if identified is None: + with self._lock: + self._frames.clear() + self._drifted = False + _log.warning('[UIStack] resync: 全量识别失败, 栈已清空') + return None + self.reset(identified, screen=screen) + _log.info('[UIStack] resync: 栈重建为 [{}]', identified) + return identified + + def snapshot(self, page: str | PageName) -> np.ndarray | None: + """返回 *page* 最近一次的留存截图; 无留存返回 ``None``。""" + name = _page_name(page) + with self._lock: + for frame in reversed(self._frames): + if frame.page == name: + return frame.screen + return None diff --git a/autowsgr/ui/start_screen_page.py b/autowsgr/ui/start_screen_page.py index 89a33200..23f0bf46 100644 --- a/autowsgr/ui/start_screen_page.py +++ b/autowsgr/ui/start_screen_page.py @@ -18,7 +18,13 @@ from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger -from autowsgr.vision import MatchStrategy, PixelChecker, PixelRule, PixelSignature +from autowsgr.vision import ( + MatchStrategy, + PageMatch, + PixelChecker, + PixelRule, + PixelSignature, +) if TYPE_CHECKING: @@ -81,17 +87,24 @@ def __init__(self, ctrl: AndroidController) -> None: # ── 页面识别 ────────────────────────────────────────────────────────── @staticmethod - def is_current_page(screen: np.ndarray) -> bool: + def is_current_page(screen: np.ndarray) -> PageMatch: """判断截图是否为启动画面。 通过底部横幅暖黄色调像素签名匹配判定。 + 返回带匹配比例的 :class:`PageMatch` + (``PageMatch.__bool__`` 保证旧式真值调用不变)。 Parameters ---------- screen: 截图 (HxWx3, RGB)。 """ - return PixelChecker.check_signature(screen, PAGE_SIGNATURE).matched + result = PixelChecker.check_signature(screen, PAGE_SIGNATURE) + return PageMatch( + name=PAGE_SIGNATURE.name, + matched=result.matched, + score=result.ratio, + ) # ── 操作动作 ────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/tabbed_page.py b/autowsgr/ui/tabbed_page.py index 38d09c79..1809a5f0 100644 --- a/autowsgr/ui/tabbed_page.py +++ b/autowsgr/ui/tabbed_page.py @@ -72,7 +72,7 @@ import numpy as np from autowsgr.types import PageName -from autowsgr.vision import Color, PixelChecker +from autowsgr.vision import Color, PageMatch, PixelChecker # from autowsgr.infra.logger import get_logger @@ -248,7 +248,7 @@ def _coverage(test: np.ndarray, template: np.ndarray) -> float: return float((test & template).sum()) / test_sum -def _match_page_type(screen: np.ndarray) -> TabbedPageType | None: +def _match_page_type(screen: np.ndarray) -> tuple[TabbedPageType, float] | None: """通过模板匹配识别标签页面类型。 对标签栏区域二值化后,与 5 个参考模板逐一比较覆盖度, @@ -261,8 +261,9 @@ def _match_page_type(screen: np.ndarray) -> TabbedPageType | None: Returns ------- - TabbedPageType | None - 覆盖度最高的页面类型,无模板时返回 ``None``。 + tuple[TabbedPageType, float] | None + ``(页面类型, 覆盖度)``;覆盖度需 > 0.6 才视为命中。 + 无模板或无命中时返回 ``None``。 """ templates = _get_templates() if not templates: @@ -276,7 +277,9 @@ def _match_page_type(screen: np.ndarray) -> TabbedPageType | None: if score > 0.6 and score > best_score: best_score = score best_type = page_type - return best_type + if best_type is None: + return None + return best_type, best_score # ═══════════════════════════════════════════════════════════════════════════════ @@ -353,7 +356,8 @@ def identify_page_type(screen: np.ndarray) -> TabbedPageType | None: if not is_tabbed_page(screen): return None - return _match_page_type(screen) + matched = _match_page_type(screen) + return matched[0] if matched else None def make_tab_checker( @@ -395,3 +399,32 @@ def _check(screen: np.ndarray) -> bool: return identify_page_type(screen) == page_type return _check + + +def check_tabbed_page(screen: np.ndarray, page_type: TabbedPageType) -> PageMatch: + """识别截图是否为指定标签页面类型,返回带覆盖度分数的 :class:`PageMatch`。 + + 供 5 个标签页面 (地图/建造/强化/任务/好友) 的 ``is_current_page`` 注册到 + :func:`~autowsgr.ui.page.register_page` 使用:把两层检测 + (标签栏验证 + 模板覆盖度) 的结果统一为 ``score``,让页面注册中心 + 能在候选集内按分数排序、消歧。 + + Parameters + ---------- + screen: + 截图 (HxWx3, RGB)。 + page_type: + 期望的页面类型。 + + Returns + ------- + PageMatch + ``matched=True`` 时 ``score`` 为该页面的模板覆盖度 (0.6, 1.0]。 + """ + page_name = str(page_type.value) + if not is_tabbed_page(screen): + return PageMatch(name=page_name, matched=False, score=0.0) + matched = _match_page_type(screen) + if matched is None or matched[0] != page_type: + return PageMatch(name=page_name, matched=False, score=0.0) + return PageMatch(name=page_name, matched=True, score=matched[1]) diff --git a/autowsgr/ui/utils/navigation.py b/autowsgr/ui/utils/navigation.py index a2e7234e..4f8676e4 100644 --- a/autowsgr/ui/utils/navigation.py +++ b/autowsgr/ui/utils/navigation.py @@ -92,11 +92,18 @@ def wait_for_page( handle_overlays: bool = True, # noqa: ARG001 source: str = '', target: str = '', + candidates: set[str] | None = None, ) -> np.ndarray: """反复截图,直到 ``checker`` 返回 ``True``。 内置浮层消除。遇到可消除浮层时立即处理并继续轮询(不计入睡眠延迟)。 + Parameters + ---------- + candidates: + 当前页识别的候选页面名集合 (来自 UIStack), 收缩轮询日志中 + 全量识别的搜索空间;``None`` 时全量识别。 + Raises ------ NavigationError @@ -118,7 +125,7 @@ def wait_for_page( ) return screen - current = get_current_page(screen) + current = get_current_page(screen, candidates=candidates) _log.debug( '[UI] 等待 #{}: {} -> {}, 当前={}', attempt, @@ -147,15 +154,32 @@ def wait_leave_page( handle_overlays: bool = True, # noqa: ARG001 source: str = '', target: str = '', -) -> np.ndarray: + candidates: set[str] | None = None, + probe: bool = False, +) -> np.ndarray | None: """反复截图,直到 ``checker`` 返回 ``False`` (已离开)。 目标页面签名未采集时的降级方案。优先使用 :func:`wait_for_page`。 + Parameters + ---------- + candidates: + 当前页识别的候选页面名集合 (来自 UIStack), 收缩轮询日志中 + 全量识别的搜索空间;``None`` 时全量识别。 + probe: + 探测模式 — 超时是**预期结果之一** (如战役次数用尽的出征探测)。 + 超时不抛异常、不保存 NavError 截图,返回 ``None`` 交由调用方判定; + 超时日志降为 debug,避免预期分支污染错误记录。 + + Returns + ------- + np.ndarray | None + 到达新页面时返回该帧;``probe=True`` 且超时时返回 ``None``。 + Raises ------ NavigationError - 超时仍在原页面。 + 超时仍在原页面 (仅 ``probe=False`` 时)。 """ from autowsgr.ui.page import get_current_page @@ -168,7 +192,7 @@ def wait_leave_page( screen = ctrl.screenshot() if not checker(screen): - current = get_current_page(screen) + current = get_current_page(screen, candidates=candidates) _log.debug( '[UI] 已离开: {} -> {} (第 {} 次截图, 到达={})', source or '?', @@ -181,6 +205,15 @@ def wait_leave_page( _log.debug('[UI] 等待离开 #{}: 仍在 {}', attempt, source or '?') if time.monotonic() >= deadline: + if probe: + _log.debug( + '[UI] 离开超时 (探测): {} -> {} ({} 次截图后仍在 {})', + source or '?', + target or '?', + attempt, + source or '?', + ) + return None msg = ( f'离开超时: {source or "?"} -> {target or "?"}, ' f'{attempt} 次截图后仍在 {source or "?"}' @@ -204,6 +237,7 @@ def click_and_wait_for_page( source: str = '', target: str = '', config: NavConfig = DEFAULT_NAV_CONFIG, + candidates: set[str] | None = None, ) -> np.ndarray: """点击 + 等待到达目标页面,内置重试。 @@ -222,6 +256,7 @@ def click_and_wait_for_page( handle_overlays=config.handle_overlays, source=source, target=target, + candidates=candidates, ) @@ -315,6 +350,7 @@ def click_and_wait_leave_page( source: str = '', target: str = '', config: NavConfig = DEFAULT_NAV_CONFIG, + candidates: set[str] | None = None, ) -> np.ndarray: """点击 + 等待离开当前页面,内置重试。 @@ -351,6 +387,7 @@ def click_and_wait_leave_page( handle_overlays=config.handle_overlays, source=source, target=target, + candidates=candidates, ) except NavigationError as e: last_err = e diff --git a/autowsgr/ui/utils/ship_list.py b/autowsgr/ui/utils/ship_list.py index 503a3af0..2601ae49 100644 --- a/autowsgr/ui/utils/ship_list.py +++ b/autowsgr/ui/utils/ship_list.py @@ -288,13 +288,6 @@ def _parse_bare_level(text: str, confidence: float) -> int | None: return value if is_valid_ship_level(value) else None -def _center_x(bbox: tuple[int, int, int, int] | None, width: int) -> float: - if bbox is None: - return width / 2 - x1, _, x2, _ = bbox - return (x1 + x2) / 2 - - def _probe_level_near_name( ocr: OCREngine, screen: np.ndarray, @@ -420,118 +413,3 @@ def read_ship_level_at_card( name_x=card_x * w, max_x=list_w_native, ) - - -def read_ship_levels( - ocr: OCREngine, - screen: np.ndarray, - *, - deduplicate_by_name: bool = True, - include_row_key: bool = False, -) -> list[tuple[str, int | None] | tuple[str, int | None, float, float]]: - """在选船列表页识别各舰船的名称及等级。 - - 使用与 :func:`locate_ship_rows` 相同的 DLL 行定位 + OCR 流程, - 再以每个舰名中心和 DLL 行中心裁切同一卡片的等级区域。 - - 参考 legacy ``Fleet.check_level`` 的思路, 但适配选船列表的 - 动态行布局 (由 DLL 定位) 而非固定槽位坐标。 - - Parameters - ---------- - ocr: - OCR 引擎实例。 - screen: - 选船列表页面的 V2 截图 (RGB, 任意分辨率)。 - deduplicate_by_name: - 是否按舰船名去重。默认 ``True`` 以保持兼容。 - 在同名多行场景下可设为 ``False`` 保留全部命中。 - include_row_key: - 是否在返回值中附带行标识 (row_key)。默认 ``False``。 - - Returns - ------- - list[tuple[str, int | None] | tuple[str, int | None, float, float]] - 默认返回 ``(ship_name, level)`` 列表, 按行顺序排列。 - 当 ``include_row_key=True`` 时返回 - ``(ship_name, level, cx_rel, row_key)``。 - ``cx_rel`` 用于把等级绑定到同一张卡片,不能依赖 OCR 返回顺序。 - ``level`` 为 ``None`` 表示未识别到等级。 - """ - h, w = screen.shape[:2] - - bgr_720p, scale_y, _scale_x = to_legacy_format(screen) - list_720p = bgr_720p[:, :LEGACY_LIST_WIDTH] - - dll = get_api_dll() - rows = dll.locate(list_720p) - _log.debug('[选船列表] DLL 定位到 {} 行候选项 (等级识别)', len(rows)) - - list_w_native = int(w * LEGACY_LIST_WIDTH / LEGACY_WIDTH) - list_area_native = screen[:, :list_w_native] - - found: list[tuple[str, int | None] | tuple[str, int | None, float, float]] = [] - seen: set[str] = set() - for y_start_720, y_end_720 in rows: - y_start = max(0, int((y_start_720 - 1) * scale_y)) - y_end = min(h, int((y_end_720 + 1) * scale_y)) - row_key = round((y_start + y_end) / 2 / h, 4) - - row_img = list_area_native[y_start:y_end] - results = ocr.recognize(row_img) - - name_hits = [ - (name, _center_x(result.bbox, row_img.shape[1])) - for result, name in _match_ship_results(results) - ] - - # 等级约束路径也仅在原图舰名失败时执行一次 2x 放大。 - if not name_hits: - upscaled = cv2.resize( - row_img, - None, - fx=2, - fy=2, - interpolation=cv2.INTER_CUBIC, - ) - upscaled_results = ocr.recognize(upscaled) - name_hits = [ - (name, _center_x(result.bbox, upscaled.shape[1]) / 2) - for result, name in _match_ship_results(upscaled_results) - ] - if not name_hits: - continue - - name_hits.sort(key=lambda item: item[1]) - - for row_name, name_x in name_hits: - if deduplicate_by_name and row_name in seen: - continue - - row_level = _probe_level_near_name( - ocr, - screen, - y_start=y_start, - y_end=y_end, - name_x=name_x, - max_x=list_w_native, - ) - - if deduplicate_by_name: - seen.add(row_name) - _log.debug( - '[选船列表] 等级识别命中: name={} level={} row_key={}', - row_name, - row_level if row_level is not None else 'None', - row_key, - ) - if include_row_key: - found.append((row_name, row_level, name_x / w, row_key)) - else: - found.append((row_name, row_level)) - - _log.debug( - '[选船列表] 等级识别: {}', - [(entry[0], entry[1]) for entry in found], - ) - return found diff --git a/autowsgr/vision/__init__.py b/autowsgr/vision/__init__.py index 0cbbed60..c40cc6c7 100644 --- a/autowsgr/vision/__init__.py +++ b/autowsgr/vision/__init__.py @@ -46,6 +46,7 @@ ShipNameMismatchError, apply_ship_patches, ) +from .page_match import PageMatch from .pixel import ( Color, CompositePixelSignature, @@ -79,6 +80,7 @@ # ocr 'OCREngine', 'OCRResult', + 'PageMatch', 'PixelChecker', 'PixelDetail', 'PixelMatchResult', diff --git a/autowsgr/vision/image_matcher.py b/autowsgr/vision/image_matcher.py index 469b84b0..f3fadc5c 100644 --- a/autowsgr/vision/image_matcher.py +++ b/autowsgr/vision/image_matcher.py @@ -103,6 +103,36 @@ def _scale_template_if_needed( scaled = cv2.resize(template_img, (new_w, new_h), interpolation=interp) return scaled + # ── 反蒙版还原 ── + + @staticmethod + def _unmask(img: np.ndarray, factor: float) -> np.ndarray: + """还原半透明黑罩压暗的画面 (反蒙版)。 + + overlay (半透明黑罩) 把整页按 ``factor`` 压暗后, RGB 的 uint8 量化 + 会压碎低亮度区的高频纹理, 导致基础页模板匹配失败。此处按 + ``RGB /= factor`` 还原原始亮度 (clamp 255), 让被压暗的基础模板重新 + 可匹。**模板图本身未经压暗, 故 unmask 只作用于截图, 不作用于模板。** + + Parameters + ---------- + img: + 截图区域 (HxWx3, RGB, uint8)。 + factor: + 压暗系数 ``(0, 1]``。``<= 0`` 时透传原图 (零拷贝, 不做任何处理); + 典型值 ``0.33`` 表示画面被压暗到约 1/3 亮度。 + + Returns + ------- + np.ndarray + 还原后的图像 (uint8)。``factor <= 0`` 时原样返回原对象。 + """ + if factor <= 0: + return img + restored = img[..., :3].astype(np.float32) / factor + np.clip(restored, 0, 255, out=restored) + return restored.astype(np.uint8) + # ── 核心匹配 ── @staticmethod @@ -112,10 +142,14 @@ def _match_single_template( roi: ROI | None = None, confidence: float = 0.85, method: int = cv2.TM_CCOEFF_NORMED, + unmask_factor: float = 0.0, ) -> ImageMatchDetail | None: """对单个模板执行匹配(内部方法)。 当截图分辨率与模板采集分辨率不同时,自动缩放模板。 + + ``unmask_factor > 0`` 时, 先对截图搜索区域做反蒙版还原 (见 + :meth:`_unmask`), 再转灰度匹配; 模板图不还原。 """ h, w = screen.shape[:2] roi = roi or ROI.full() @@ -143,6 +177,9 @@ def _match_single_template( ) return None + # 反蒙版还原: 还原被半透明黑罩压暗的截图区域 (模板不还原) + cropped = ImageChecker._unmask(cropped, unmask_factor) + screen_gray = cv2.cvtColor(cropped, cv2.COLOR_RGB2GRAY) template_gray = cv2.cvtColor(tmpl_img, cv2.COLOR_RGB2GRAY) result = cv2.matchTemplate(screen_gray, template_gray, method) @@ -202,6 +239,7 @@ def match_rule(screen: np.ndarray, rule: ImageRule) -> ImageMatchResult: roi=rule.roi, confidence=rule.confidence, method=rule.method, + unmask_factor=rule.unmask_factor, ) if detail is not None: all_details.append(detail) @@ -278,9 +316,12 @@ def find_template( *, roi: ROI | None = None, confidence: float = 0.85, + unmask_factor: float = 0.0, ) -> ImageMatchDetail | None: """在截图中查找单个模板(等价于旧代码 ``locate_image_center``)。""" - return ImageChecker._match_single_template(screen, template, roi=roi, confidence=confidence) + return ImageChecker._match_single_template( + screen, template, roi=roi, confidence=confidence, unmask_factor=unmask_factor + ) @staticmethod def find_any( @@ -289,11 +330,12 @@ def find_any( *, roi: ROI | None = None, confidence: float = 0.85, + unmask_factor: float = 0.0, ) -> ImageMatchDetail | None: """查找多个模板中的任意一个(等价于旧代码 ``image_exist``)。""" for tmpl in templates: detail = ImageChecker._match_single_template( - screen, tmpl, roi=roi, confidence=confidence + screen, tmpl, roi=roi, confidence=confidence, unmask_factor=unmask_factor ) if detail is not None: return detail @@ -306,12 +348,13 @@ def find_best( *, roi: ROI | None = None, confidence: float = 0.85, + unmask_factor: float = 0.0, ) -> ImageMatchDetail | None: """查找多个模板中置信度最高的一个。""" best: ImageMatchDetail | None = None for tmpl in templates: detail = ImageChecker._match_single_template( - screen, tmpl, roi=roi, confidence=confidence + screen, tmpl, roi=roi, confidence=confidence, unmask_factor=unmask_factor ) if detail is not None and (best is None or detail.confidence > best.confidence): best = detail @@ -324,6 +367,7 @@ def find_all( *, roi: ROI | None = None, confidence: float = 0.85, + unmask_factor: float = 0.0, ) -> list[ImageMatchDetail]: """查找所有匹配的模板。""" return [ @@ -331,7 +375,11 @@ def find_all( for tmpl in templates if ( d := ImageChecker._match_single_template( - screen, tmpl, roi=roi, confidence=confidence + screen, + tmpl, + roi=roi, + confidence=confidence, + unmask_factor=unmask_factor, ) ) is not None @@ -344,11 +392,17 @@ def template_exists( *, roi: ROI | None = None, confidence: float = 0.85, + unmask_factor: float = 0.0, ) -> bool: """判断模板是否存在于截图中(等价于旧代码 ``image_exist``)。""" if isinstance(templates, ImageTemplate): templates = [templates] - return ImageChecker.find_any(screen, templates, roi=roi, confidence=confidence) is not None + return ( + ImageChecker.find_any( + screen, templates, roi=roi, confidence=confidence, unmask_factor=unmask_factor + ) + is not None + ) @staticmethod def identify( @@ -375,10 +429,12 @@ def find_all_occurrences( confidence: float = 0.85, max_count: int = 20, min_distance: int = 10, + unmask_factor: float = 0.0, ) -> list[ImageMatchDetail]: """查找单个模板的所有出现位置(非极大值抑制去重)。 当截图分辨率与模板采集分辨率不同时,自动缩放模板。 + ``unmask_factor > 0`` 时对截图搜索区域做反蒙版还原 (模板不还原)。 """ h, w = screen.shape[:2] roi = roi or ROI.full() @@ -397,6 +453,9 @@ def find_all_occurrences( if th > ch or tw_ > cw: return [] + # 反蒙版还原 (同 _match_single_template) + cropped = ImageChecker._unmask(cropped, unmask_factor) + screen_gray = cv2.cvtColor(cropped, cv2.COLOR_RGB2GRAY) template_gray = cv2.cvtColor(tmpl_img, cv2.COLOR_RGB2GRAY) result = cv2.matchTemplate(screen_gray, template_gray, cv2.TM_CCOEFF_NORMED) diff --git a/autowsgr/vision/image_template.py b/autowsgr/vision/image_template.py index 42e59d16..f4f0110b 100644 --- a/autowsgr/vision/image_template.py +++ b/autowsgr/vision/image_template.py @@ -234,6 +234,11 @@ class ImageRule: 匹配置信度阈值 (0.0-1.0)。 method: OpenCV 模板匹配方法,默认 ``cv2.TM_CCOEFF_NORMED``。 + unmask_factor: + 反蒙版还原系数 ``(0, 1]``, 默认 ``0.0`` (禁用)。``> 0`` 时对截图搜索区域 + 做 ``RGB /= factor`` 还原半透明黑罩压暗 (见 + :meth:`~autowsgr.vision.image_matcher.ImageChecker._unmask`), 用于 overlay + 场景下识别被压暗的基础页模板; 模板图本身不还原。 Examples -------- @@ -250,6 +255,7 @@ class ImageRule: roi: ROI = field(default_factory=ROI.full) confidence: float = 0.85 method: int = cv2.TM_CCOEFF_NORMED + unmask_factor: float = 0.0 def __post_init__(self) -> None: if isinstance(self.templates, list): diff --git a/autowsgr/vision/ocr_rules.py b/autowsgr/vision/ocr_rules.py index c4510e47..8b3f7f6e 100644 --- a/autowsgr/vision/ocr_rules.py +++ b/autowsgr/vision/ocr_rules.py @@ -34,11 +34,7 @@ from autowsgr.constants import ( expand_ship_name_candidates as expand_group_candidates, ) -from autowsgr.constants import ( - get_ship_name_group_id, - normalize_ship_name, - set_ship_name_aliases, -) +from autowsgr.constants import get_ship_name_group_id, set_ship_name_aliases from autowsgr.infra.logger import get_logger from autowsgr.types import ShipType @@ -165,7 +161,6 @@ def get_fastocr_params(profile: FastOCRProfile | str) -> FastOCRParams: ) # EasyOCR 在极窄等级区域中偶尔只保留 ``L.``,丢失中间的 ``V``。 LEVEL_SHORT_PATTERN = re.compile(r'[Ll][\.:]?\s*([0-9ILilOoDdSsBb]{1,6})') -LEVEL_LABEL_PATTERN = re.compile(r'[LlIi1O0][VvYy1Ii]') def set_user_ship_name_corrections(corrections: Mapping[str, str]) -> int: @@ -215,24 +210,6 @@ def set_user_ship_name_aliases(aliases: Mapping[str, str]) -> int: return len(loaded) -def get_user_ship_name_aliases(ship_name: str) -> tuple[str, ...]: - """返回标准舰名对应的全部游戏内自定义名,结果不依赖配置顺序。""" - name = ship_name.strip() - if not name: - return () - if name in _USER_SHIP_NAME_ALIASES: - return (name,) - - identity = normalize_ship_name(name) - return tuple( - sorted( - alias - for alias, standard_name in _USER_SHIP_NAME_ALIASES.items() - if normalize_ship_name(standard_name) == identity - ) - ) - - def expand_ship_name_candidates(candidates: list[str]) -> list[str]: """将当前舰名候选扩展为同组全部名称。""" return expand_group_candidates(candidates) diff --git a/autowsgr/vision/page_match.py b/autowsgr/vision/page_match.py new file mode 100644 index 00000000..972ac80f --- /dev/null +++ b/autowsgr/vision/page_match.py @@ -0,0 +1,44 @@ +"""页面识别统一结果类型 PageMatch。 + +页面识别(判断当前处于哪个 UI 页面)横跨三种引擎: + +- 像素签名 :class:`~autowsgr.vision.matcher.PixelChecker`(输出 matched_count/total_count → ratio) +- 模板匹配 :class:`~autowsgr.vision.image_matcher.ImageChecker`(输出 confidence) +- 标签页覆盖度 :mod:`autowsgr.ui.tabbed_page`(输出覆盖度) + +PageMatch 把三者归一化为统一的 ``score`` (0.0-1.0),让页面注册中心 +:func:`autowsgr.ui.page.get_current_page` 能在候选集内按分数排序、取最高分, +替代旧的"首次匹配即胜 + 布尔"逻辑。 + +本模块为纯数据模型,不含检测逻辑。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PageMatch: + """单个页面的识别结果。 + + Attributes + ---------- + name: + 页面名称 (PageName 字符串)。 + matched: + 是否达到该页面的匹配门槛 + (像素签名 ALL 命中 / 模板 ≥ 置信度阈值 / tabbed 覆盖度超阈)。 + score: + 匹配强度,范围 0.0-1.0。像素=matched/total,模板=confidence,tabbed=覆盖度。 + 即使 ``matched=False`` 也可能携带部分分数(如像素签名 3/4 命中得 0.75), + 供候选集排序消歧使用。 + """ + + name: str + matched: bool + score: float + + def __bool__(self) -> bool: + """``matched=True`` 视为真,兼容 ``if checker(screen):`` 写法。""" + return self.matched diff --git a/autowsgr/vision/roi.py b/autowsgr/vision/roi.py index 91c258e7..49630e0c 100644 --- a/autowsgr/vision/roi.py +++ b/autowsgr/vision/roi.py @@ -116,6 +116,19 @@ def to_absolute(self, width: int, height: int) -> tuple[int, int, int, int]: int(self.y2 * height), ) + def expand_pixels(self, width: int, height: int, padding: int = 1) -> ROI: + """Return this ROI expanded by a pixel margin at the given resolution.""" + if width <= 0 or height <= 0 or padding < 0: + raise ValueError('ROI resolution and padding must be non-negative') + dx = padding / width + dy = padding / height + return ROI( + max(0.0, self.x1 - dx), + max(0.0, self.y1 - dy), + min(1.0, self.x2 + dx), + min(1.0, self.y2 + dy), + ) + def crop(self, screen: np.ndarray) -> np.ndarray: """从截图中裁切出 ROI 区域。 diff --git a/config.json b/config.json new file mode 100644 index 00000000..60883a84 --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "serial": "127.0.0.1:16384" +} \ No newline at end of file diff --git a/docs/architecture/combat-engine.md b/docs/architecture/combat-engine.md index eada6fc8..49192d76 100644 --- a/docs/architecture/combat-engine.md +++ b/docs/architecture/combat-engine.md @@ -141,6 +141,11 @@ START_FIGHT → FIGHT_CONDITION → SPOT_ENEMY_SUCCESS → FORMATION → MAP_PAGE (终止) ``` +配置 `grade` (节点战果要求, 见 `NodeDecision.grade` / `CombatPlan.conditions`) +的计划: `RESULT` 的后继改为经验结算页 `EXP_SETTLEMENT` (慢速, 逐页推进 +采集评级/MVP); 无要求计划快速穿行, 不经过经验页。见 +`CombatPlan.collect_result_info` (由 `conditions` 派生)。 + 分支节点: - `SPOT_ENEMY_SUCCESS` → `fight` (进入战斗) / `detour` (迂回) / `retreat` (撤退) diff --git a/docs/architecture/context-and-config.md b/docs/architecture/context-and-config.md index e31430f2..8ef5ff15 100644 --- a/docs/architecture/context-and-config.md +++ b/docs/architecture/context-and-config.md @@ -100,6 +100,10 @@ UserConfig (顶层) └── remove_equipment_mode: bool # 解装前卸装备 ``` +### 延迟边界 + +`operation_delay_min` 与 `operation_delay_max` 均为非负有限秒数。无效值在配置加载时被拒绝,避免设备操作阶段传给 `time.sleep()` 后才失败。 + ### 子配置详解 #### EmulatorConfig diff --git a/docs/architecture/vision.md b/docs/architecture/vision.md index 39c44244..b1e92e8e 100644 --- a/docs/architecture/vision.md +++ b/docs/architecture/vision.md @@ -99,8 +99,9 @@ if PixelChecker.is_matching(screen, MAIN_PAGE_SIG): ```python ROI(x1, y1, x2, y2) # 感兴趣区域 (相对坐标 0-1) - .crop(screen) -> np.ndarray # 裁剪截图 - .invert() # 取反区域 + .crop(screen) -> np.ndarray # 裁剪截图 (返回视图, 非拷贝) + .to_absolute(w, h) # 转绝对像素坐标 + .contains(x, y) # 判断点是否在区域内 ImageTemplate( image: np.ndarray, # 模板图片 (HxWx3, RGB) @@ -108,7 +109,8 @@ ImageTemplate( source_resolution: (960, 540) # 采集分辨率 ) -ImageRule(templates, strategy) # 多模板 + 匹配策略 +ImageRule(templates, strategy, unmask_factor=0.0) # 多模板 + 匹配策略 + # unmask_factor: 截图搜索区域反蒙版还原因子 (见“反蒙版还原”小节) ImageSignature(rules, strategy) # 多规则组合签名 ImageMatchDetail(found, confidence, location, template_name) @@ -124,13 +126,21 @@ class ImageChecker: # 截图分辨率不同时自动缩放模板 @staticmethod - def _match_single_template(screen, template, roi, confidence, method) + def _unmask(img, factor) + # 反蒙版还原: img / factor → clip(0,255); factor<=0 透传 + + @staticmethod + def _match_single_template(screen, template, roi, confidence, method, unmask_factor=0.0) # 单模板匹配 → ImageMatchDetail | None + # unmask_factor>0 时对截图搜索区域 (非模板) 做 _unmask 后再匹配 @staticmethod def fetch_all_templates(screen, signature, roi) -> list[ImageMatchDetail] def is_matching(screen, signature, roi) -> bool def find_match(screen, signature, roi) -> ImageMatchDetail | None + + # 便捷方法均接受 unmask_factor 关键字 (默认 0.0): + # find_template / find_any / find_best / find_all / template_exists / find_all_occurrences ``` ### 分辨率适配 @@ -140,6 +150,14 @@ class ImageChecker: - 缩小: `cv2.INTER_AREA`(抗锯齿) - 放大: `cv2.INTER_LINEAR` +### 反蒙版还原 (unmask) + +`_unmask(img, factor)` 把被半透明黑罩压暗的画面除以 `factor` 还原亮度(借鉴 WSG-NCC 的 `unmask=0.33`)。通过 `ImageRule.unmask_factor` 字段或各 `find_*` 方法的 `unmask_factor` 参数启用,默认 `0.0`(禁用,完全向后兼容)。 + +- **作用范围**:仅还原截图搜索区域,**模板保持原始亮度**(模板是干净基准)。 + +> ⚠️ **TM_CCOEFF_NORMED 缩放不变性**:相关系数在正标量乘法下不变,故纯乘性均匀压暗本就不破坏模板匹配置信度(实测压暗屏幕置信度仍 ~0.998),**unmask 对模板匹配几乎无意义**。其价值在于像素级 MAE 对比(如“操作驱动状态追踪”中对比 click 前后边缘像素),以及未来含加性偏移的场景。 + --- ## Layer 3: OCR 文字识别 diff --git a/docs/developer/fleet-current-member-validation-todo.md b/docs/developer/fleet-current-member-validation-todo.md new file mode 100644 index 00000000..c82c9245 --- /dev/null +++ b/docs/developer/fleet-current-member-validation-todo.md @@ -0,0 +1,200 @@ +# 当前舰队严格校验修复 TODO + +## 问题背景 + +用户日志 `用户反馈260805-1-gui_2026-08-05.debug (1).log` 中,准备页已经成功识别当前舰队: + +```text +密苏里、岛风、普林斯顿、大黄蜂、企业、不挠 +``` + +目标规则包含 `ship_type`。现有换船逻辑虽然知道目标舰船已经在当前舰队中,但仍会打开原槽位,在选船页搜索同一艘舰船。游戏会隐藏已上阵舰船,导致程序把“当前已上阵”误判成“仓库中不存在”。 + +日志中的直接表现: + +- 岛风、普林斯顿、大黄蜂各搜索失败 3 次。 +- 密苏里只命中舰种为“战列”的结果,不满足目标“导战”。 +- 主选被加入本轮 `unavailable`,重新分配后仍无可用方案。 +- 最终报错“目标槽位 3 的主选和备选均不可用”,调度器随后重复执行。 + +## 当前实现问题 + +- `FleetSnapshot` 只保存舰名和槽位占用,没有保存准备页已经具备识别能力的等级。 +- `ship_type`、`min_level`、`max_level` 被统一视为“必须进入选船页校验”。 +- 已上阵但尚未严格验证的舰船会直接在原槽位重新搜索,没有先让该舰船重新出现在选船列表。 +- 搜索失败无法区分“舰船已上阵而被隐藏”和“舰船确实不可用”。 +- 当前准备页没有舰种识别实现。现有截图已确认每张舰船卡片上存在稳定的舰种文字行,但仍需在真实 `1280x720` 画面中校准六个槽位的裁剪坐标。 + +相关代码: + +- `autowsgr/ui/battle/fleet_change/_detect.py` +- `autowsgr/ui/battle/fleet_change/_change.py` +- `autowsgr/ui/battle/detection.py` +- `autowsgr/ui/battle/constants.py` +- `autowsgr/ui/choose_ship_page.py` +- `testing/ui/battle_preparation/test_unit.py` +- `testing/ui/test_choose_ship_page.py` + +## 目标行为 + +对当前已上阵且舰名匹配目标的舰船,先在准备页完成能够完成的校验: + +```text +准备页识别成功且满足规则 + -> 原地保留,不进入选船页 + +准备页多次识别结果明确不满足规则 + -> 执行正常替换,寻找其他同名舰或备选舰 + +准备页多次识别后仍然未知 + -> 才进入严格校验兜底流程 + -> 必须先让当前舰船离队,再到选船页搜索和验证 +``` + +“未知”不能当成“满足”,也不能直接把主选标记为不可用。 + +## 前置调查 + +- [ ] 在模拟器获取真实 `1280x720` 出征准备页截图,覆盖 6 个槽位。 +- [ ] 截图至少覆盖不同舰种、改造前后舰船、不同皮肤和不同血量状态。 +- [ ] 标定每个槽位的舰种文字行,确认裁剪区域不包含等级、舰名和右侧锁定图标。 +- [ ] 舰种识别使用与等级识别一致的固定归一化裁剪坐标和文字 OCR,不引入舰种图片模板。 +- [ ] 校准现有 `SHIP_LEVEL_CROP`。当前常量注释明确说明坐标是估算值。 +- [ ] 使用 `tools/pixel_marker.py` 标记区域左上角和右下角。 +- [ ] 将像素坐标转换为相对坐标: + +```text +(x1 / width, y1 / height, x2 / width, y2 / height) +``` + +- [ ] 确认选船页是否在所有入口中都隐藏当前已上阵舰船。 +- [ ] 记录第一舰队只剩一艘舰船时,游戏是否允许移除或替换。 + +## 实现任务 + +### 1. 扩展当前舰队快照 + +- [ ] 为 `FleetSnapshot` 增加逐槽等级: + +```python +levels: tuple[int | None, ...] +``` + +- [ ] 复用 `DetectionMixin._recognize_fleet_levels()`,不要另写一套等级解析。 +- [ ] 为 `FleetSnapshot` 增加逐槽舰种;识别失败的槽位必须保存为“未知”,不得猜测。 +- [ ] 保持现有舰名归一化实现不变,不修改或删除 `_change.py` 中的舰名归一化逻辑。 + +### 2. 提升准备页等级识别 + +- [ ] 对同一槽位使用 2~3 张新截图重试,不以单次 OCR 失败作为最终结果。 +- [ ] 尝试原图、灰度、对比度增强和二值化版本。 +- [ ] 保留现有 4 倍上采样。 +- [ ] 多次一致结果才判定为“明确等级”。 +- [ ] 记录每次 OCR 原文、解析值和最终判定。 +- [ ] 仅在调试截图配置启用时保存失败裁图,避免正常运行产生大量文件。 + +### 3. 实现准备页舰种识别 + +- [ ] 在 `constants.py` 新增六槽 `SHIP_TYPE_CROP`,格式与 `SHIP_LEVEL_CROP` 完全一致: + +```python +SHIP_TYPE_CROP: dict[int, tuple[float, float, float, float]] = { + 0: (x1, y1, x2, y2), + 1: (x1, y1, x2, y2), + 2: (x1, y1, x2, y2), + 3: (x1, y1, x2, y2), + 4: (x1, y1, x2, y2), + 5: (x1, y1, x2, y2), +} +``` + +- [ ] 所有坐标以真实 `1280x720` 截图为基准并转换为归一化坐标,不直接使用 `1024x768` 截图的相对坐标。 +- [ ] 六个槽位分别裁图和识别,不把整条舰种文字区域合并为一张长图。 +- [ ] 裁图放大 4~6 倍,并尝试原图、灰度、对比度增强和二值化版本。 +- [ ] 只提取括号前的舰种文字,忽略国家信息,例如将 `战巡(G国)` 解析为 `战巡`。 +- [ ] 舰种结果必须使用 22 个后端标准 `ShipType`,不引入另一套映射。 +- [ ] 多次一致才判定为“明确舰种”。 +- [ ] 无法识别返回“未知”,不得猜测为目标舰种。 +- [ ] 保存失败裁图和原始识别结果,便于后续修正规则。 + +### 4. 拆分严格校验决策 + +- [ ] 不再使用一个布尔值统一处理 `ship_type/min_level/max_level`。 +- [ ] 为每个当前成员输出三态结果: + - `MATCH`:已确认满足所有硬条件。 + - `MISMATCH`:已确认至少一个硬条件不满足。 + - `UNKNOWN`:至少一个硬条件无法确认。 +- [ ] 仅有等级条件且准备页等级满足时,直接保留当前成员。 +- [ ] 等级明确超出范围时,进入正常替换。 +- [ ] 舰种明确不在允许集合时,进入正常替换。 +- [ ] 未知结果完成规定次数的重试后,进入选船页兜底验证。 + +### 5. 修复已上阵舰船的兜底验证 + +- [ ] 禁止在未离队的情况下搜索同一艘当前舰船。 +- [ ] 一次只处理一艘需要兜底验证的舰船,不要整队清空。 +- [ ] 让目标舰船离队后,立即重新截图并刷新 `names/occupied/levels`。 +- [ ] 处理移除引起的槽位左移,禁止继续使用旧物理槽位。 +- [ ] 重新执行全局唯一分配,继续保证主选优先。 +- [ ] 从选船页重新选择目标舰船,并严格验证舰种和等级。 +- [ ] 选船页真实失败后,才能把主选加入本轮 `unavailable`。 +- [ ] 进入备选前必须重新执行全局唯一分配。 +- [ ] 保持主选和备选调整总次数上限为 48。 +- [ ] 主选和备选全部失败时直接抛错退出,不静默保留错误编成。 + +### 6. 第一舰队保护 + +- [ ] 第一舰队任何时刻都不能变成空队。 +- [ ] 优先填充其他目标成员,再处理需要离队验证的最后一艘。 +- [ ] 如果第一舰队只有一艘且必须进行选船页兜底验证,先加入临时成员。 +- [ ] 无法找到临时成员时明确报错,不跳过严格验证。 +- [ ] 正常成员调整仍遵循“先填缺失,再删除多余”;兜底验证只临时释放当前目标,并且逐艘恢复。 + +### 7. 日志与诊断 + +- [ ] 日志明确区分: + - 当前成员准备页验证通过。 + - 当前成员明确不满足规则。 + - 当前成员准备页识别未知。 + - 当前成员已离队,进入选船页兜底验证。 + - 选船页真实不存在或不满足规则。 +- [ ] 舰种 OCR 记录每个裁图区的原文、置信度和最终映射。 +- [ ] 等级 OCR 记录多次结果和最终采用值。 +- [ ] 不再把“已上阵而不可见”记录为“舰船不存在”。 + +## 测试任务 + +- [ ] 当前舰名匹配且只有等级限制,准备页等级满足:不得打开选船页。 +- [ ] 当前舰名匹配且等级明确不满足:替换为满足等级的同名舰或备选舰。 +- [ ] 当前舰名匹配但等级连续识别失败:进入兜底验证。 +- [ ] 当前舰名匹配且准备页舰种满足:原地保留。 +- [ ] 当前舰名匹配但舰种明确不满足:进入正常替换。 +- [ ] 当前舰名匹配但舰种未知:离队后重新选择,不能直接搜索当前舰船。 +- [ ] 离队造成槽位压缩后,后续物理槽位和逻辑目标槽位仍然正确。 +- [ ] 当前主选验证失败后,主选永久排除于本轮后续尝试。 +- [ ] 主选失败进入备选时,重新执行全局唯一分配。 +- [ ] 冲突时优先保留可用主选,备选不得抢占主选舰船。 +- [ ] 第一舰队始终至少保留一艘舰船。 +- [ ] 调整次数达到 48 时立即失败。 +- [ ] 所有主选和备选失败时抛错并退出。 +- [ ] 更新现有“带约束的当前成员直接重新选择”单测,删除错误行为断言。 +- [ ] 增加真实准备页截图测试,覆盖等级和舰种识别的成功、失败、未知。 + +## 模拟器验收 + +- [ ] 使用与用户日志相同的六舰配置复现,确认岛风、普林斯顿、大黄蜂不再因已上阵而被误判为不存在。 +- [ ] 验证密苏里“战列/导战”能够正确区分,不因同名舰选择错误。 +- [ ] 验证等级满足、等级不满足、等级无法识别三种路径。 +- [ ] 验证准备页舰种识别(若实现)的所有可见舰种。 +- [ ] 验证舰队未满、舰队满员、第一舰队单舰和第二舰队满员。 +- [ ] 验证任务失败后页面和舰队状态可恢复,不产生调度器重复级联错误。 +- [ ] PR 前必须完成模拟器验证,不能只依赖 Mock 单测。 + +## 验收标准 + +- 当前已上阵舰船不会因选船列表隐藏而被误判为不存在。 +- 准备页已经能够确认的等级或舰种不会再次进入选船页。 +- “满足”“不满足”“未知”具有不同且可追踪的处理路径。 +- 任何硬条件未知时都不会被当作满足。 +- 主选优先、全局唯一分配、48 次上限和失败退出语义保持不变。 +- 最终舰队顺序、舰种和等级均经过严格验证。 diff --git a/docs/features/dead-code-analysis.md b/docs/features/dead-code-analysis.md index c152399f..a4d2c66b 100644 --- a/docs/features/dead-code-analysis.md +++ b/docs/features/dead-code-analysis.md @@ -2,11 +2,13 @@ ## 1. 分析范围 -- 分支:`refactor/fleet-change-modules` -- 依赖基线:PR #537(`fix/smart-fleet-change-phase-1`,提交 `f4d584b`) +- 分支:`ShiinaKuroko` +- 基线提交:`3195808` - 扫描范围:`autowsgr/`、`testing/`、`examples/`、`tools/` 和 `docs/` - 目标:区分可安全删除的内部残留、必须保留的兼容接口,以及不能仅凭静态引用判断的公开 API +工作区中的 scrcpy 实验改动、基准图片和未提交 OCR 文档不在本次分析范围内。 + ## 2. 分析方法 本次分析同时使用以下证据,避免直接采用静态扫描结果: @@ -50,27 +52,17 @@ from autowsgr.ui.battle.fleet_change import FleetChangeMixin | 模块 | 可删除对象 | | --- | --- | -| `combat/actions.py` | `FLAGSHIP_CONFIRM`、`click_start_march` | -| `combat/fleet.py` | `NATIVE_FLEET_VESSEL_TYPES` | -| `combat/recognition.py` | `_SHIP_TYPE_DISPLAY_MAP` | -| `combat/rules.py` | `_SHIP_TYPE_PATTERN` | -| `ops/normal_fight.py` | `self._destroy_ship_types` 赋值 | -| `ops/startup.py` | `_OVERLAY_DISMISS_TIMEOUT`、`_OVERLAY_DISMISS_DELAY` | -| `emulator/controller/scrcpy.py` | `_TYPE_INJECT_SCROLL_EVENT` | -| `ui/bath_page/recognition.py` | `_TIME_Y_MIN` | -| `ui/decisive/fleet_ocr.py` | `_prepare_name_roi` | -| `ui/decisive/overlay.py` | `CLICK_BUY_EXP`、`CLICK_SKILL`、`is_advance_choice`、`is_confirm_exit` | +| `combat/actions.py` | `FLAGSHIP_CONFIRM` | | `ui/main_page/constants.py` | `EVENT_SIDEBAR_BG` | -| `ui/map/data.py` | `EXPEDITION_IDLE_COLOR`、`SIDEBAR_SCAN_*`、`RIVAL_POSITIONS`、`CLICK_CHALLENGE` | +| `ui/map/data.py` | `SIDEBAR_SCAN_*` | | `ui/tabbed_page.py` | `TAB_DARK` | -| `ui/utils/ship_list.py` | `_center_x` | -| `vision/ocr_rules.py` | `LEVEL_LABEL_PATTERN` | -| `server/schemas.py` | `SystemStatusResponse`、`LogMessage`,以及随之失去引用的 `LogLevel` | | `server/ws_manager.py` | `send_log`,以及仅由它使用的 `UTC`、`datetime` 导入 | -| `image_resources/_lazy.py` | 未被读取的 `self._attr_name` 赋值 | -这些项目适合后续按模块分批删除,并为对应模块执行聚焦测试。本次提交不同时清理, -以便将旧模块删除与其他行为变化隔离。 +本次精简批次已删除前表中已移除的私有死代码:`click_start_march`、 +`_SHIP_TYPE_DISPLAY_MAP`、`_SHIP_TYPE_PATTERN`、`self._destroy_ship_types` 赋值、 +两个未使用的启动常量、scrcpy 滚动消息常量、浴室时间常量、舰队 OCR 名称预处理 +helper、舰船列表中心点 helper、等级标签正则,以及 `LazyTemplate` 未读取的属性赋值。 +删除前均通过全仓引用扫描确认无调用。剩余项目继续按模块分批删除,并执行对应聚焦测试。 ## 5. 必须保留的兼容接口 diff --git a/docs/features/primary-first-smart-fleet.md b/docs/features/primary-first-smart-fleet.md new file mode 100644 index 00000000..b0830957 --- /dev/null +++ b/docs/features/primary-first-smart-fleet.md @@ -0,0 +1,150 @@ +# 主选优先智能换船 + +## 功能目标 + +智能换船根据六个槽位的主选、备选、舰种和等级规则,在出征准备页复用已有舰船、 +补齐缺失成员、删除多余成员并调整顺序。 + +核心保证: + +- 主选存在且满足约束时,不能因为当前舰队已有备选而放弃主选。 +- 备选只在槽位没有主选,或主选经过选船页确认不可用时参与分配。 +- 同一舰船及其标准名、别名、改造后缀只能占用一个槽位。 +- type/level 严格约束必须经过选船页验证,不能只依赖准备页舰名 OCR。 +- 成员集合完成后才执行最终排序,避免补船和删除导致重复拖拽。 +- 最终舰名、顺序、空槽和同舰唯一性全部通过后才能进入战斗。 + +## 配置语义 + +每个 `FleetSlotRule` 表示一个逻辑槽位: + +- `primary`:严格主选。存在时优先级最高。 +- `candidates`:有序备选。每个备选保留自己的舰名、搜索名、舰种和等级条件。 +- candidate-only:没有主选但至少有一个备选的槽位。 + +以下配置直接判定无效: + +- 一个槽位既没有主选也没有备选。 +- 两个槽位配置为同一舰船身份的主选。 +- 保留全部可用主选后,剩余槽位无法分配为互不重复的完整编成。 +- 第一舰队的最终槽位 0 为空。 + +主选同时出现在其他槽位的备选中时,该舰船身份保留给主选。多个备选槽位发生冲突时, +通过全局回溯选择完整编成,不能由较早处理的槽位局部抢占。 + +## 四阶段流程 + +```mermaid +flowchart LR + A[阶段1 构建主选保留表和 OCR 目标池] --> B[阶段2 识别当前舰队和槽位占用] + B --> C[阶段3 主选优先补齐成员集合] + C --> D[阶段4 删除多余成员并最终排序验证] + style A fill:#bbdefb,color:#0d47a1 + style B fill:#fff3e0,color:#e65100 + style C fill:#c8e6c9,color:#1a5e20 + style D fill:#f3e5f5,color:#7b1fa2 +``` + +### 阶段 1:构建规则 + +1. 主选槽位先保留主选身份。 +2. candidate-only 槽位保留完整备选集合。 +3. 统一标准舰名、用户别名和明确后缀对应的舰船身份。 +4. 检查重复主选和全局唯一分配是否可行。 +5. 构建 OCR 目标池:全部主选与全部备选。 + +OCR 目标池只用于完整船池识别失败时的安全补救,不会把备选提升为最终目标,也不会把 +已经可靠识别出的非目标舰船强制改成目标舰船。 + +### 阶段 2:识别当前舰队 + +舰名 OCR 与血条槽位探针使用同一张截图,组合出四种状态: + +- 已识别目标成员。 +- 已识别多余成员。 +- 血条探针确认的空槽。 +- 血条探针确认有舰船,但舰名未识别的未知槽位。 + +未知槽位不能当成空槽。存在空槽或已识别多余成员时优先使用这些槽位;没有其他位置时, +才允许用未知槽位放置缺失的明确目标,最终 OCR 必须再次确认实际结果。 + +严格 type/level 主选即使已经识别到舰名,也需要在它当前所在槽位重新进入选船页验证。 + +### 阶段 3:补齐成员集合 + +全局目标按以下顺序确定: + +1. 保留全部尚未确认不可用的主选。 +2. candidate-only 优先复用当前舰队已有且未被主选保留的备选。 +3. 其余备选按配置顺序参与全局唯一分配。 +4. 同等可行方案优先减少换船操作。 + +执行时只保留已满足目标的成员,不立即拖拽。缺失目标依次使用: + +1. 确认空槽。 +2. 已识别的多余舰船槽位。 +3. 无其他位置时的未知占用槽位。 + +主选选择失败分为两类: + +- 业务不可用:选船页找不到舰船,或严格舰种/等级不满足。标记该主选不可用,重新执行 + 全局分配,再尝试不占用其他主选的备选。 +- 技术错误:页面跳转、OCR 服务或控制器异常。直接重试或失败,不能把技术错误解释为 + 主选不可用。 + +选船页实际选择了备选后,必须锁定该槽位的实际结果并重新计算其他未完成槽位,避免局部 +fallback 抢占另一个槽位的主选或已锁定舰船。 + +### 阶段 4:清理、排序和验证 + +1. 从后往前删除补齐后仍然多余的舰船,减少槽位压缩影响。 +2. 删除后重新识别成员集合和槽位占用。 +3. 缺员时先补齐,不在成员不完整时执行最终拖拽。 +4. 成员完整后一次性排序到配置槽位。 +5. 排序后使用逐槽目标上下文再次 OCR。 +6. 验证舰名、位置、空槽、同舰唯一性和 strict 选船页校验记录。 + +验证失败时只修正错误槽位。成员缺失优先补船,位置错误只排序,多余成员先删除再识别。 +达到最大重试次数仍不满足时返回失败。 + +## 示例 + +配置主选为: + +```text +[A, B, C, D, E, F] +``` + +当前舰队为: + +```text +[X, A, C, E, Y, Z] +``` + +OCR 识别后保留 `A/C/E`,缺失 `B/D/F`,`X/Y/Z` 是可替换成员。补齐后可能得到: + +```text +[B, A, C, E, D, F] +``` + +成员集合已经正确,不再换船,最后一次性排序为: + +```text +[A, B, C, D, E, F] +``` + +如果第一次只识别到 `A/C`,但血条探针确认 E 所在槽位有舰船,该槽位记为未知。算法优先 +使用其他空槽或已识别多余槽位;仍无法确认 E 时主动选择 E,最终逐槽验证必须识别到 E。 + +## 验收条件 + +- 当前舰队已有备选时,仍然先尝试可用主选。 +- 主选确实不可用后才选择备选。 +- candidate-only 可以复用当前已有候选。 +- 候选不能占用其他槽位的主选。 +- 重复主选在首次 OCR 前失败。 +- strict existing member 必须经过选船页验证。 +- OCR 漏识别的已占用槽位不能被当成空槽。 +- 第一舰队始终先替换或补船,再删除多余成员。 +- 成员集合完成前不执行最终排序。 +- 最终验证结果直接提供给 runner,不再进行无上下文 OCR。 diff --git a/docs/ocr_research.md b/docs/ocr_research.md new file mode 100644 index 00000000..e00de805 --- /dev/null +++ b/docs/ocr_research.md @@ -0,0 +1,173 @@ +# 主流手游自动化脚本 OCR / 图像识别方案调研 + +> 调研目的:解决 AutoWSGR「编队页等级/舰种 OCR 识别不到」问题,参考主流开源自动化项目的成熟方案。 +> 调研日期:2026-08-06。来源:mirrorchyan 镜像站项目列表 + 各项目 GitHub/官方文档。 + +## 1. 问题背景 + +- AutoWSGR 当前 OCR 输入图来自 **scrcpy H264 视频流解码帧**(8Mbps 有损),不是 adb 无损截图。 +- 现象:编队页等级(小字数字)反复识别不到;尝试换 EasyOCR 引擎后仍未解决。 +- 待验证假设:① scrcpy 画质糊导致 OCR 输入差;② 通用 OCR 模型不认战舰少女R 游戏字体。 + +## 2. 调研范围 + +mirrorchyan(https://mirrorchyan.com/zh/projects)上列出的 50+ 开源手游自动化项目,按技术流派归类(已全部核实): + +| 流派 | 代表项目 | 识别核心技术 | +|---|---|---| +| MaaFramework 系(C++,图色识别为主) | MRA《战舰少女R》、MaaYuan、MaaBD2、MAA_SnowBreak、MAA_Punish、MaaFgo、识宝、SSAH、Maa_KES、MaaLYSK、MaaYYs、M9A、火影忍者MAA、MAATree、Maa_MHXY_MG、MaaResonance、MaaEnd、MaaNTE、MaaGF2Exilium、MDA、MaaGakumasu | 模板匹配 + OCR(内置 FastOCR) | +| ALAS 系(Python,OCR 为重) | ALAS(碧蓝)、SRA / StarRailCopilot(星铁) | OCR(自训模型)为主 | +| 独立 Python 系 | BAAS/BAAH(蔚蓝档案)、BlueArchive_Copyninja、LALC/AALC、三月七小助手 | 模板匹配 + 独立 OCR 服务 / RapidOCR | +| PC 后台系(ok-script 系) | ok-gi / ok-gf2 / ok-ww / ok-ef、绝区零一条龙 | OpenCV 模板匹配 + PP-OCR(ONNX) + YOLO | +| 安卓端 | 明日方舟速通(ArkLights) | 无障碍截图 + PaddleOCR + 模板 | +| 其他 | Auto_Resonance(NEMUIPC)、BetterGI(多模型融合)、DoroHelper(AHK FindText)、ok-nte(自研CV+音频)、BetterNTE(Rust) | 多样 | +| 专用 OCR | LuLing-OCR(AutoWSGR 专用) | CRNN 专训模型 | + +## 3. 已调研项目详查 + +### 3.1 MAA(MaaAssistantArknights,明日方舟) + +- 截图:**启动时实测各方案自动选最快的**(PR #15608 实测数据): + - 模拟器专属增强(MumuExtras / LDExtras / AVD 共享内存):**0~3ms**(雷电 9 V9.1.32+ / MuMu V4.1.26+ 官方支持) + - `adb exec-out "screencap | gzip -1"`(raw+gzip,PC 端解压,无损):**~120ms** + - `adb exec-out screencap -p`(PNG 设备端编码):**~370-390ms**,最慢 + - 结论:PNG 编码在模拟器内做很慢,raw+gzip 无损且快 3 倍。 +- 识别:静态 UI 以模板匹配为主;OCR 仅用于少量文字(干员名等),支持内置 ONNX PaddleOCR / Windows OCR / RPC 外部服务。 +- 触控:minitouch / MaaTouch / maatouch(socket 注入),不用 adb input。 +- 文档:https://docs.maa.plus/zh-cn/manual/connection.html + +### 3.2 ALAS(AzurLaneAutoScript,碧蓝航线,纯 Python) + +- 截图:adbutils + uiautomator2,ADB screencap。性能基准:**低配 >1s、一般 ~0.5s、高配 ~0.3s**(README 明示)。 +- OCR(module/ocr/ocr.py,一手源码): + - **为游戏字体专门训练模型**:`lang='azur_lane'`、`azur_lane_jp`,bin/ocr 存专训模型。 + - 预处理 `extract_letters`:按文字颜色抽字(白色 RGB 255,255,255)+ 阈值二值化 → 白字黑底单通道再喂 OCR。 + - **字符白名单** `alphabet='0123456789IDSB'`:限定输出字符集。 + - **后处理纠错** `I→1, D→0, S→5, B→8`。 + - 支持 RPC OCR 服务器模式(UseOcrServer)、YUV 亮度通道识别抗颜色干扰。 + - 引擎:cnocr 1.2.2 + mxnet(早期)/ PaddleOCR。 +- 仓库:https://github.com/LmeSzinc/AzurLaneAutoScript + +### 3.3 SRA / StarRailCopilot(崩坏星穹铁道) + +- 基于 ALAS 下一代框架,OCR 管线沿用 ALAS(PaddleOCR/cnocr 系 + 自训模型)。 +- **强制推荐模拟器分辨率 1280x720**(和 MAA 一样,低分辨率反而有利于识别稳定)。 +- 仓库:https://github.com/LmeSzinc/StarRailCopilot + +### 3.4 BAAS(蔚蓝档案) + +- **OCR 用 C++ 重构为独立子进程**(BAAS_Cpp),通过 HTTP + 共享内存通信,主程序不背 OCR 负担。 +- 用途:BOSS 血量、倒计时、物资数量、关卡 ID、角色名(和 AutoWSGR 的"等级/类型"完全同类)。 +- 声称短文本毫秒级;多语言;Windows/Linux/macOS/Android 跨平台预编译。 +- 识别分工(官方文档):静态元素(按钮/图标)用**模板匹配**(1ms 内,CPU 即可);动态文字用 OCR。 +- 仓库:https://github.com/pur1fying/BAAS_Cpp 、https://baas.wiki/develop_doc/script/ocr.html + +### 3.5 MRA(战舰少女R 小助手) + +- 基于 MaaFramework + MFAAvalonia + MXU,与 AutoWSGR 同游戏竞品。 +- 功能:常规图/炸鱼/捞胖次、远征、战役、演习、决战、造船、活动练级。 +- 识别:沿用 MaaFramework 的模板匹配为主 + OCR 辅助。 +- 官网:https://saratoga-official.github.io/MRA/ + +### 3.6 LuLing-OCR(AutoWSGR 专用 OCR,重大发现) + +- OpenWSGR 组织开源,明写"为 AutoWSGR(战舰少女R 自动化框架)提供游戏 UI 文字识别能力"。 +- **CRNN 架构**:5 层 CNN + 2 层 BiLSTM + CTC 解码,8.9M 参数,模型 ~102MB,CPU 推理 < 100ms。 +- **大字库**:7000 常用汉字 + 116 符号/字母(共 7117 类)。 +- **在线合成训练**:用思源黑体(Source Han Sans SC)字体渲染合成数据,无需收集标注真实截图。 +- 预处理:自动颜色通道选择、对比度拉伸、极性检测。 +- 仓库:https://github.com/OpenWSGR/LuLing-OCR + +### 3.7 Tesseract 系通用实践(轻量脚本) + +- 预处理共识:裁剪 ROI → 灰度 + 自适应二值化 → 形态学去噪 → **放大 2-3 倍**(INTER_CUBIC)→ 字符白名单 `tessedit_char_whitelist` → `--psm 7` 单行 / `--psm 6` 文本块。 +- 自定义字体:jTessBoxEditor 训练专属 `.traineddata`。 +- 参考:https://www.volcengine.com/article/820588 + +### 3.8 ok-script 系(PC 后台自动化,源码级确认) + +统一特征:**PP-OCR 模型转 ONNX + OpenVINO/NPU 加速**(`onnxocr-ppocrv4/v5`),OCR 是"基础设施"而非主力,主力是 OpenCV 模板匹配(COCO 标注素材、多分辨率自适应)+ HSV 找色。 + +| 项目 | OCR 引擎 | 关键发现 | +|---|---|---| +| ok-script(框架) | 不捆绑,项目自配 | 提供 `wait_click_ocr`、**`add_text_fix` 文字修正表**(对应 AutoWSGR 的 correction rules)、名称匹配支持 str/正则/列表 | +| ok-gi(原神,已停维护) | PP-OCRv4 ONNX + OpenVINO | 模板匹配 + YOLO + OCR 分工;win32 后台截图 | +| ok-gf2(少前2) | PP-OCRv4 ONNX + OpenVINO | **HSV 颜色隔离预处理**(源码 `frame_processs.py` + `hsv_config.py`):按白/金/灰字 HSV 范围 inRange → 形态学闭运算 → 纯文字图再喂 OCR;README 踩坑"主页背景必须暗色,否则识别不到文字" | +| ok-ww(鸣潮) | PP-OCRv5 ONNX + OpenVINO/NPU | **YOLOv8 ONNX 目标检测**用于声骸/敌人识别(`OnnxYolo8Detect.py`,letterbox + NMS);声骸文字 OCR 在 GUI 中可开关(耗 CPU) | +| ok-ef(终末地) | PP-OCRv5 ONNX + OpenVINO | OCR 结果接 **CSV 词表匹配/纠错链路**(装备词条识别管线);有独立《i18n 与 OCR 配置流程》文档 | + +### 3.9 绝区零一条龙(ZenlessZoneZero-OneDragon)—— 小字识别参考价值最大 + +- 截图:mss + pyautogui;OCR 用 **onnxruntime-directml**。 +- OCR:自研封装 PP-OCRv5/v6(`onnx_ocr_matcher.py`),`use_angle_cls=False`、`det_limit_side_len=960`。 +- 关键工程优化(PR #1756): + - **crop_first:先裁剪 ROI 再 OCR**(默认裁剪后识别); + - **单行模式 `det=False` 跳过文字检测直接走 rec 识别**(`run_ocr_single_line`,固定区域单行小字又准又快); + - **LCS 最长公共子序列模糊匹配**(`match_words`,容忍 OCR 错一两个字); + - GPU 线程池串行化 + OCR 结果缓存。 +- 链接:https://github.com/OneDragon-Anything/ZenlessZoneZero-OneDragon + +### 3.10 MaaFramework 体系(MRA / MaaYuan / MaaBD2 / MAA_SnowBreak / Maa_KES / MaaLYSK / MaaYYs / M9A / 火影MAA / MAATree / Maa_MHXY_MG / MaaResonance / MaaEnd / MaaNTE / MaaGF2Exilium / MAA_Punish / 识宝 / MDA / MaaGakumasu) + +绝大多数是 MaaFramework 的**资源/pipeline 项目**,识别能力即 MaaFramework 内置: + +- **识别算法集**:`DirectHit | TemplateMatch | FeatureMatch | ColorMatch | OCR | NeuralNetworkClassify | NeuralNetworkDetect | And/Or | Custom`。**每个节点只能选一种 recognition**,静态 UI 用 TemplateMatch,动态文本用 OCR,多状态用多个节点 + `next` 链顺序尝试。 +- **OCR 实现**(`Vision/OCRer.cpp`):FastDeploy 的 **PPOCRv4(PaddleOCR→ONNX,det+rec+keys.txt)**,模型资产在 MaaCommonAssets 仓库(v3~v6 可选,pipeline `model` 字段指定)。 +- **OCR 节点关键字段**:`expected`(精确匹配)、`expected_partial`(正则/包含)、**`replace`(识别结果替换纠错)**、`need_each`、`only_rec`(跳过检测)、`roi`(限定区域)、**`color_filter`(v5.8+,颜色二值化后再 OCR,提升彩色 UI 文字识别)**、同模型 batch 合并。 +- **截图**:模拟器 adb screencap(统一缩放到 720p 内部基准);PC 端 Win32 Desktop Duplication/GDI。 +- **统一约束**:模拟器固定分辨率(1280×720 居多)、MaaYYs 提示"队伍预设名避免复杂符号降低 OCR 失败"、Maa_MHXY_MG 有"OCR 回退 V4 模型"实践、MaaGakumasu 用 **YOLOv11 自训模型**识别卡牌 + `replace` 纠错繁简字(`[["费","費"]]`)。 +- 突破模板匹配的案例:**BetterGI**(YOLO 目标检测 + PaddleOCR + SIFT/ORB 特征匹配,多模型融合)、**ok-nte**(自研 CV 战斗算法 + 音频触发)、**MaaFgo**(BBchannel 战斗内核 + Maa 视觉导航混合)、**Auto_Resonance**(NEMUIPC MuMu IPC 截图/控制)。 + +### 3.11 独立 Python 系补充 + +- **AALC / LALC(Limbus Company)**:均用 **RapidOCR**(PP-OCRv4 ONNX,requirements 实锤)。AALC 截图用 pywin32 GDI BitBlt + PrintWindow 后台;**OCR 前 CLAHE 自适应直方图均衡**(clipLimit=2.0)增强低对比度小字。LALC 用 OpenCV 模板匹配为主 + RapidOCR 辅助,**金字塔多尺度模板匹配**适配分辨率,"精细匹配"场景跳过 CLAHE 保留细节。 +- **三月七小助手(星铁)**:RapidOCR + onnxruntime-directml/openvino 硬件加速;mss 截图;OCR 用于资源数值、任务文本。 +- **BAAH(蔚蓝档案)**:模板匹配为主(数百张素材)+ **pponnxcr**(PaddleOCR→ONNX)辅助;OCR 用于战术大赛对手排名/等级、咖啡馆学生名;强约束模拟器 **1280×720 / 240DPI**;同名/近似名有专门延迟配置。 +- **DoroHelper(Nikke,已归档)**:AutoHotkey V2 + **FindText**(图像转黑白 ASCII 文本匹配,轻量伪 OCR);README 列了完整"影响识别环境"强制清单(关 HDR/色彩滤镜/悬浮窗、16:9 ≤1080p、60fps)。 +- **明日方舟速通(ArkLights,安卓端)**:懒人精灵无障碍截图 + **PaddleOCR**(按需下载);"保守识别、有问题就保留"置信度策略 + "不使用 OCR"回退开关。 + +## 4. 核心共识 + +1. **识别分工**:静态 UI(按钮/图标/页面锚点)一律模板匹配(1ms);只有动态文字(数值/名称)才上 OCR;需要"找物体/区域"时用 YOLO 类目标检测(ok-ww、BetterGI、MaaGakumasu),**不要把 OCR 当检测器用**。 +2. **OCR 输入质量优先**:无损截图是基础。主流两条路:adb raw+gzip(~120ms)或模拟器共享内存/专属 IPC(0-3ms,NEMUIPC、MuMu 增强)。 +3. **OCR 引擎选型事实标准 = PP-OCR 系(ONNX 化)**:RapidOCR / onnxocr-ppocrv4/v5 / MaaFramework FastOCR / pponnxcr 全部是 PaddleOCR 模型转 ONNX。**无人用 EasyOCR/Tesseract**。加速用 OpenVINO/DirectML。 +4. **OCR 引擎不是关键,预处理才是**: + - crop_first:**先裁剪 ROI 再 OCR**(OneDragon); + - **单行模式 det=False 禁检测直接识别**(OneDragon)——固定位置小字的制胜招; + - **HSV 颜色隔离**按文字颜色抠出文字层(ok-gf2)、**color_filter 颜色二值化**(MaaFramework v5.8+)、**CLAHE 对比度均衡**(AALC/LALC); + - 放大 2-3x + 灰度二值化(Tesseract 系)。 +5. **OCR 结果后处理**:`replace` 纠错映射表(MaaFramework 原生字段 / MaaGakumasu 繁简纠错 / ALAS I→1)、LCS 模糊匹配(OneDragon)、字符白名单(ALAS alphabet)、保守识别策略(ArkLights)。 +6. **通用模型识别不了游戏字体**:ALAS 专训模型、LuLing-OCR 已为战舰少女R 做好。 +7. **环境强约束**:固定模拟器分辨率/DPI、关 HDR/色彩滤镜/悬浮窗(DoroHelper/BAAH/ALAS 一致要求 1280×720)。 +8. **性能基准**:adb screencap 一般 ~0.5s;OCR 高频场景需缓存 + 线程池,可做成可开关项。 + +## 5. 对 AutoWSGR 的建议 + +- **短期(验证画质)**:`screenshot_native()` 走 `adb exec-out "screencap | gzip -1"`(无损 + ~120ms),与 scrcpy 帧做 A/B 对比。 +- **中期(预处理 + 后处理,抄 OneDragon 组合拳)**: + 1. 等级/舰名识别改为 **crop_first + 单行模式(det=False)**,限定 ROI; + 2. 叠加 **HSV 颜色隔离**(等级数字一般是特定颜色)或 CLAHE 增强; + 3. 识别结果用 **LCS 模糊匹配 + replace 纠错映射表** 替代堆叠海量 correction rules。 +- **引擎替换**:从 EasyOCR 迁移到 **onnxocr-ppocrv5 / RapidOCR + OpenVINO**(中文小字明显更稳、可 GPU/NPU 加速)。 +- **长期(识别率根治)**:直接用或借鉴 **LuLing-OCR**(AutoWSGR 生态现成专训模型)。 +- **架构参考**:识别器接口 + 特殊识别器注册(Maa CustomRecognition);模板匹配/OCR/YOLO 分工明确。 + +## 6. 参考链接汇总 + +- MAA:https://docs.maa.plus/zh-cn/manual/connection.html | PR #15608 https://github.com/MaaAssistantArknights/MaaAssistantArknights/pull/15608 +- ALAS:https://github.com/LmeSzinc/AzurLaneAutoScript (OCR 源码 module/ocr/ocr.py) +- StarRailCopilot:https://github.com/LmeSzinc/StarRailCopilot +- BAAS:https://baas.wiki/develop_doc/script/ocr.html | https://github.com/pur1fying/BAAS_Cpp +- MRA:https://saratoga-official.github.io/MRA/ +- LuLing-OCR:https://github.com/OpenWSGR/LuLing-OCR +- MaaFramework:https://github.com/MaaXYZ/MaaFramework | OCR 实现 https://github.com/MaaXYZ/MaaFramework/blob/main/source/MaaFramework/Vision/OCRer.cpp | 模型资产 https://github.com/MaaXYZ/MaaCommonAssets +- ok-script:https://github.com/ok-oldking/ok-script | ok-gf2:https://github.com/ok-oldking/ok-gf2 | ok-ww:https://github.com/ok-oldking/ok-wuthering-waves | ok-ef:https://github.com/AliceJump/ok-end-field +- 绝区零一条龙:https://github.com/OneDragon-Anything/ZenlessZoneZero-OneDragon | PR #1756 +- 明日方舟速通:https://github.com/AegirTech/ArkLights +- BetterGI:https://github.com/babalae/better-genshin-impact +- MaaGakumasu:https://github.com/SuperWaterGod/MaaGakumasu | replace 纠错 https://github.com/MaaXYZ/MaaFramework/issues/1080 +- AALC:https://github.com/KIYI671/AhabAssistantLimbusCompany | LALC:https://github.com/HSLix/LixAssistantLimbusCompany +- 三月七:https://github.com/moesnow/March7thAssistant | BAAH:https://github.com/BlueArchiveArisHelper/BAAH +- MDA:https://github.com/1204244136/MDA | DoroHelper:https://github.com/1204244136/DoroHelper +- Auto_Resonance:https://github.com/Night-stars-1/Auto_Resonance | MaaNTE/ok-nte/BetterNTE:https://github.com/1bananachicken/MaaNTE 等 +- 镜像站清单:https://mirrorchyan.com/zh/projects diff --git a/docs/usage/usage_combat.md b/docs/usage/usage_combat.md index 2f435480..26243192 100644 --- a/docs/usage/usage_combat.md +++ b/docs/usage/usage_combat.md @@ -96,6 +96,27 @@ node_args: | `node_defaults` | dict | `{}` | 所有节点的默认决策 | | `node_args` | dict | `{}` | 各节点独立决策 (覆盖默认) | +#### 战果要求 (node_args 的 grade) + +```yaml +# 例: 6-1 的 F 点刷 S 胜, 达标场次才计入触发器次数 +node_args: + F: + grade: S # 战果等级 (D/C/B/A/S/SS), 含义: >= 该等级 +``` + +节点配置 `grade` 后自动生效两点: + +1. **慢速结算采集**: 该计划的战果/经验结算页入状态机逐页推进, + 完整采集评级与 MVP (无要求的计划走快速穿行, 不停留)。 +2. **条件计数**: `auto_daily` 常规战触发器只把「所有配置 grade 的节点 + 战果全部达标」的场次计入 `times` 次数 (同一节点多次经过取最后一次 + 结算); 不达标的场次 (评级不足、SL 重开) 不计数, 触发器下轮继续 + 产出直到达标次数打满。 + +`grade` 也可放 `node_defaults` — 会继承给 `selected_nodes` 的所有节点 +(全部节点都要求)。 + ### 节点决策字段 | 字段 | 类型 | 默认值 | 说明 | @@ -108,6 +129,7 @@ node_args: | `SL_when_spot_enemy_fails` | bool | `False` | 索敌失败时 SL | | `SL_when_enemy_ship_type` | list | `[]` | 遇到特定舰种 SL | | `proceed_stop` | list | `[]` | 中破停止位 (如 `[1,3]`) | +| `grade` | str | `""` | 本节点要求的最低战果 (D/C/B/A/S/SS), 见下文 | ### 编程方式创建 @@ -324,7 +346,7 @@ enemy_rules: - [BB >= 2, detour] # 战列 >= 2 → 迂回 ``` -**条件格式**: `<舰种> <运算符> <数量>` +**条件格式**: `<舰种> <运算符> <数量>`。舰种代码不区分大小写,`> =`、`< =`、`! =` 形式也会兼容解析为组合运算符。 支持的舰种代号: diff --git a/findings.md b/findings.md new file mode 100644 index 00000000..277338d3 --- /dev/null +++ b/findings.md @@ -0,0 +1,375 @@ +# Findings & Decisions + +## Requirements +- Create an isolated AutoWSGR worktree under `AutoWSGR/.worktrees`. +- Base it on the committed `ShiinaKuroko` branch. +- Keep the worktree available for subsequent decisive-battle debugging. +- Do not alter the shared checkout or unrelated existing worktrees. + +## Research Findings +- The coordination repository is `C:/ShiinaKuroko/01.Project`. +- The target backend repository is `C:/ShiinaKuroko/01.Project/AutoWSGR`. +- The shared checkout is on `ShiinaKuroko` with pre-existing user changes; its committed tip is `c5a464c7719bea74a7a26079b9656644237ff8cb`. +- Existing `c4d8` and `9c2e` worktrees are respectively an in-progress repair-method task and a completed enemy-rule task, so neither matches this new decisive-battle task. +- The new worktree is clean apart from task-scoped planning files. +- The worktree is bound to the current Agent identity. +- The repository contains a dedicated decisive-battle UI E2E module at `testing/ui/decisive_battle_page/e2e.py`, a shared runner at `testing/ui/run_all_e2e.py` and `testing/ui/run_all_e2e.ps1`, plus focused unit/operation tests under `testing/ops/test_decisive_unit.py` and `testing/ops/decisive_battle.py`. +- `examples/decisive.py` is the user-facing decisive-battle example and may be a safer static entry point than the UI E2E runner until its device requirements are confirmed. +- The requested complete tool exists only in the old `20260830-autowsgr-upgrade` worktree as the tracked `tools/e2e/` package (`run.py`, `framework.py`, and cases); the current `ShiinaKuroko`-based worktree does not contain that package. +- The old worktree has unrelated uncommitted migration changes and a separate uncommitted edit to `tools/e2e/cases/bath_repair.py`; only the committed `tools/e2e/` source should be considered for transfer. +- The committed `tools/e2e/` package was restored into the bound worktree, its framework was adapted to the current `autowsgr.scheduler`, `autowsgr.context`, `autowsgr.infra`, and `autowsgr.ui` APIs, and a current-architecture `decisive` case was added. +- The E2E argument splitter originally rejected the documented `screenshot --no-launch` form; it now accepts global flags before or after the case name. +- The copied framework originally called `Launcher.disconnect()`, which does not exist in the current launcher; cleanup now disconnects `launcher.ctrl` and avoids reporting a failure when connection never completed. +- The read-only screenshot E2E passed on `127.0.0.1:16384`: device connected at 1280x720, screenshot succeeded, page recognition returned `主页面`, and cleanup completed with exit code 0. +- The first real decisive run used the current `usersettings.yaml` (chapter 1, one round) and reached the decisive overview, reset the chapter, entered the map, then timed out after 8 seconds waiting for `fleet_acquisition` during `CHOOSE_FLEET`; the controller returned `ERROR`. +- The decisive E2E cleanup returned to the main page and disconnected the device. The only transport warning was scrcpy `WinError 10038` during socket shutdown. +- The GUI source file `AutoWSGR-GUI/resource/system_daily_plans/decisive-决战第6章.yaml` defines chapter 6, one round, quick repair enabled, level1 `[U-47, U-1405, U-1206, U-2540, U-81, U-96]`, and a 21-entry level2 list. +- The GUI plan contract has no flagship-priority, repair-level, full-destroy, or useful-skill fields; the backend default file now uses the GUI values for shared fields and retains its existing backend-only values for those fields. + +## Technical Decisions +| Decision | Rationale | +|----------|-----------| +| `codex/20260907-autowsgr-decisive-debug-6e3a` | Unique local task branch for this debug effort. | +| `C:/ShiinaKuroko/01.Project/AutoWSGR/.worktrees/20260907-autowsgr-decisive-debug-6e3a` | Repository-owned worktree path requested by the user. | +| Use `c5a464c` as the base | It is the current committed `ShiinaKuroko` tip. | + +## Issues Encountered +| Issue | Resolution | +|-------|------------| +| Shared checkout contains many user deletions and untracked debug/planning data | Left the shared checkout untouched and created the worktree from its committed branch tip. | +| The planning initializer writes a UTF-8 BOM | Recreated the generated files with the required task header using the direct edit tool. | +| One parallel read used a nonexistent old worktree path | Recorded the path error and reran inspection against the bound worktree path. | +| Direct system Python lacked locked dependencies | Ran `uv sync --all-groups` in the bound worktree. | +| The first E2E import probe had a PowerShell quoting error | Reran the import probe through a PowerShell here-string; imports passed. | +| Documented E2E flag order was rejected | Fixed `tools/e2e/run.py` argument splitting and added a direct assertion check. | +| Current launcher has no `disconnect()` method | Fixed the copied framework to disconnect `launcher.ctrl` and verified the screenshot E2E. | +| Decisive run timed out waiting for `fleet_acquisition` | Stopped further real-device runs and recorded the failure; wait for the user's configuration before rerunning. | + +## Configuration Source +- Source: `C:/ShiinaKuroko/01.Project/AutoWSGR-GUI/resource/system_daily_plans/decisive-决战第6章.yaml` (read-only). +- Applied to: `usersettings.yaml` in the bound AutoWSGR decisive-debug worktree. + +## Resources +- `C:/ShiinaKuroko/01.Project/AGENTS.md` +- `C:/ShiinaKuroko/01.Project/AutoWSGR/AGENTS.md` +- `C:/Users/mzhia/.codex/skills/planning-with-files/SKILL.md` + +## Recognition Fix +- Additional fallback requirement: `CHOOSE_FLEET` must not be trusted solely from the state enum. Before OCR or any fleet click, the current screen must positively match the fleet-acquisition button/template; otherwise the controller must re-detect the current phase and route without assuming first-entry, retreat, or leave-resume state. +- The existing OpenCV template check is present in `wait_for_fleet_overlay_stable()`, but its current timeout path raises directly instead of re-routing from a fresh phase detection. `_has_chosen_fleet` is also set before that validation and should move after successful fleet-page completion. +- The new recovery-chain E2E contract has four cases: initial advance choice and normal fleet selection followed by retreat; retreat re-entry with mocked empty fleet selection followed by retreat; retreat re-entry with normal formation followed by leave; and leave resume with no advance choice, stopping on preparation without starting battle. +- The recovery-chain mock is tool-only and is removed from production logic; it returns empty fleet options/one-fleet sufficiency only in case 2. +- Correction: the `_use_last_fleet_attempts` hypothesis was rejected for this user case; it is an in-task retreat/leave path, not a new task resuming through the “use last fleet” entry. +- The clarified three-path contract is now explicit: first entry may show advance choice, same-task retreat re-entry may show it again, and leave/resume at an already selected point may show no choice. +- After an advance choice is confirmed, the controller returns to `WAITING_FOR_MAP` and lets fresh visual recognition choose `CHOOSE_FLEET` or `PREPARE_COMBAT`; it no longer hardcodes the next phase. +- The user-provided debug log is the actual chapter-6 case: after the first stage-1 entry and retreat, the second entry at `00:15:17` again logged `选择前进点` and then waited for `fleet_acquisition` until timeout. +- Earlier `_use_last_fleet_attempts` recovery was considered but rejected for this same-task retreat/leave path; the current fix relies on fresh visual entry recognition instead. +- The decisive code already has a positive `ADVANCE_CHOICE` template match at `autowsgr/image_resources/pages/decisive.py` and overlay detection at `autowsgr/ui/decisive/overlay.py`; `DecisiveMapController.detect_decisive_phase()` checks this overlay before classifying the map page. +- The unsafe behavior was at the state/action boundary: entry used a fixed 2-second delay, and `select_advance_card()` clicked coordinates without rechecking that the choice overlay was visible. +- The fix removes the fixed delay, stages positive `USE_LAST_FLEET` and `ADVANCE_CHOICE` checks before the map-page fallback, routes a confirmed map page directly to `PREPARE_COMBAT`, and gates advance-card clicks on a positive `ADVANCE_CHOICE` template match. +- The no-popup paths do not call `select_advance_card()`: only a positive `ADVANCE_CHOICE` overlay result enters `DecisivePhase.ADVANCE_CHOICE`; resume/terminal paths route to `PREPARE_COMBAT` or `STAGE_CLEAR`. +- Before the map archive was added, the backend decisive data exposed only `map_end`, `key_points`, and `enemy`; the new per-EX files now provide the route graph and are the runtime source. +- The first mock E2E case design was invalid: it reused the normal entry path and reached `使用上次舰队` before the insufficient-fleet injection, so it did not isolate the requested same-task retreat/re-entry chain. The mock flag was removed without changing production code. +- The captured chapter-6 log proves the advance card was not clicked blindly: `00:34:12.559` detected `advance_choice`, `00:34:12.609` entered `ADVANCE_CHOICE`, then `00:34:12.612` clicked the card and `00:34:13.126` clicked confirm. +- The earlier chapter-1 failure was a different bug: at `00:21:30.224`, missing ship-marker recognition caused the old fallback to force `PREPARE_COMBAT → CHOOSE_FLEET`, which later timed out waiting for `fleet_acquisition`; it did not click an unrecognized advance popup. + +## Tomorrow: Event Page False Positive +- `BaseEventPage.is_current_page()` first checks the generic `event/fight_button_20260730_540p.png` at confidence `0.8`. +- On the decisive formation screenshot `logs/e2e_tools/decisive/20260908_034922/images/NavError_034216_477.png`, that matcher returned confidence `0.869940996170044` at normalized center `(0.130078125, 0.050694444444444445)`, which is the top-left back button, not an event attack button. +- Difficulty-icon and event-title checks were both `None`; the false hit won because `EVENT_MAP` is registered before other page candidates. +- Narrow fix for tomorrow: constrain the event fight-button matcher to the real bottom-right activity-button ROI and add an offline regression using this screenshot. Do not change decisive reset or combat logic for this issue. + +## Decisive Map Data Semantic Unification (2026-09-09) + +- The normal-map contract is one YAML file per map with node keys and `position` plus directed `next` edges. Decisive data should reuse the `next` meaning, but its source does not provide reliable pixel positions, so positions must remain absent until measured from real screenshots. +- `silent_warrior_forward_map.yaml` covers 18 maps (`EX-1-1` through `EX-6-3`), 319 branch-qualified nodes, and 401 directed edges. Node IDs are `0` or `{label}{branch_number}` such as `A1`, `A2`, and `J3`. +- `enemy_formations.yaml` covers the same 18 maps, but keys are base labels (`A` through `J`). Joining is therefore `node_id -> label` by removing the numeric suffix; duplicate branch instances intentionally share the same source formation. +- All 18 route graphs have one terminal base label. Runtime terminal and key-point queries now derive directly from each EX file. +- The current decisive state and DLL recognizer retain only the base node letter, while `get_advance_choice()` always returns index `0`. Route data can be archived now, but branch-qualified tracking and route-aware card selection require a separate state/API decision. +- The supplied enemy data uses human-readable classes (`轻巡`, `驱逐`, `战列`, etc.); the canonical archive maps them through the existing type contract and legacy decisive aliases (`CL`, `DD`, `BB`, `BG`, `BBG`, etc.). In the decisive format, `大巡 -> BG` and `导战 -> BBG`; `机场` is not a normal `ShipType`, so it retains `AF`. +- The old key-point table contained unreachable labels for some shorter maps (for example `J` in `EX-1-3` and `H` in `EX-2-1`); the normalized files retain only reachable key points. +- Added 18 `autowsgr/data/map/decisive_battle/silent_warrior/EX-*.yaml` files as an offline archive only; production loading and state-machine behavior are intentionally unchanged in this phase. +- The leading empty element in legacy enemy arrays is a 1-based compatibility sentinel, not an enemy slot. `MapData.get_enemy()` filters it with `if x`; the new `silent_warrior` files store actual enemy codes only and leave the legacy file untouched. + +## Formation Back-Return Root Cause +- The latest `BATTLE_PREP -> MAP` timeout occurs after the back click succeeds; failure screenshots are already on the decisive map. +- `BattlePreparationPage.go_back()` waits for generic `PageName.MAP`, whose tabbed-map checker does not recognize the decisive map layout. +- `DecisiveMapController.is_decisive_map_page()` does recognize that screen, but it is not the checker used by the preparation-page return path. +- The event false positive is a secondary first-frame misclassification; after it disappears, the generic MAP target still returns `None` and causes the timeout. + +## Stability Initial-State Diagnosis (2026-09-10) + +- The stability E2E runner's normal `prepare()` path calls `_initialize_game()`, which returns the game to the home page before the case starts. That violates the requirement to start from the device's actual state. +- `decisive_stability._reset_after_restart()` immediately calls `detect_entry_status()` without first proving that the current screen is the decisive overview. A main-page device therefore fails before ticket execution. +- `decisive_stability._new_controller()` unconditionally calls `_prepare_entry_state()`, sets `_resume_mode=True`, and forces `ENTER_MAP`; this overwrites an already active decisive map/overlay context. +- A read-only diagnostic at 2026-09-10 04:27 on serial `127.0.0.1:16384` recognized the actual screen as the main page. Decisive map, fleet overlay, advance-choice overlay, and entry status were all absent. The correct next action is normal navigation to the configured decisive chapter, not a reset. +- A decisive map screen does not expose the subsection number through the current map recognizers. If the process starts on an active map and no persisted stage exists, the harness must fail closed rather than assume stage 1/2/3. + +## Six-Ticket Stability Run Result (2026-09-10) + +- Initial state was detected rather than assumed: main page -> Ex-6 `challenging`, stage 2. The first run reached the real fleet overlay after retreat re-entry; after the state-gating fix, fleet OCR and fallback selection were exercised. +- The chapter reset path passed (`challenging -> reset_button -> confirm -> refreshed`). The clean-start run used `--force-reset-start` only after earlier diagnostic attempts had polluted the partial state. +- No ticket reached combat. Formation scans repeatedly found one usable ship, so the controller correctly entered system retreat at node A. Resetting the chapter did not replenish the actual last fleet; this is a device/configuration precondition failure. +- The expedition interval was 900 seconds and the run stopped before the first interval, so no expedition collection was counted. + +## Fleet Primary/Backup Selection Contract (2026-09-12) + +- The current `choose_ships()` baseline selected only `config.level1` when the fleet had at most one ship, and only scanned `level2` in the `first_node` branch. `DecisiveState.is_begin()` returns false for Ex-6 stage 2, node A, so the real run selected one primary ship and stopped. +- `logic.py` had no uncommitted changes before this fix; the observed behavior came from the existing branch condition, not the earlier E2E fallback-card change. +- Updated incomplete-fleet candidates to deterministic `level1` first, then configured `level2` ship backups at every node. Full-fleet skill/priority behavior remains separate, and duplicate first-node backup selection is prevented. +- Added tests for non-first-node backup selection and primary-before-backup ordering. + +## Fleet Overlay Timeout Root Cause (2026-09-10) + +- The failure screenshot from the interrupted six-ticket run is a real `战备舰队获取` page, not an unknown map. Re-saving the same 1280x720 screen with an ASCII filename produced a template score of `0.9999333` inside `FLEET_ACQUISITION_ROI (493, 37, 800, 108)`; thresholds `0.5` through `0.9` all pass. +- The missed detection was state gating: after an earlier entry with no advance-choice popup, `_handle_waiting_for_map()` intentionally set `_fleet_overlay_enabled=False` for temporary-leave recovery. A later retreat reset the map state but `_execute_retreat()` did not re-enable that context flag, so the next advance-choice -> fleet-overlay path skipped fleet recognition entirely. +- Fixed the shared `_execute_retreat()` transition to set `_fleet_overlay_enabled=True` only after retreat confirmation succeeds. `_execute_leave()` remains false. Added a regression test for the two opposite transitions. +- The original E2E failure screenshots were zero-byte files because the failure step label contained `:` in a Windows filename. The E2E framework now sanitizes failure screenshot tags to ASCII-safe names. + +## Fleet Close Requires A Selected Card (2026-09-10) + +- A clean real-device probe reached `CHOOSE_FLEET` with score 10 and no planned purchase. Directly clicking the red `关闭` button left the overlay open; selecting the first card first, then clicking the same close action, closed it successfully (`close_result=True`, `after_close=False`). +- The production handler therefore needs a lowest-cost fallback card when OCR/decision returns no purchase. This is a UI prerequisite, not a template or coordinate issue; the state then keeps `_force_fleet_scan=True` so current-fleet sufficiency is still evaluated. +- Added the fallback and a focused unit test. Empty OCR selections still retain the existing close-failure/retreat fallback. + +## Stability Run Reset Failure (2026-09-09) + +- The long-run process completed ticket 1, then repeatedly saw entry status `refresh` and failed inside `reset_chapter()` before any confirmation dialog was opened. +- The captured `refresh` overview shows a large bottom-center `重置关卡` button matching `Templates.Decisive.ENTRY_REFRESH` at about `(0.43, 0.88)-(0.63, 0.98)` on 1280x720. +- `reset_button.png` is a small circular-arrow control at about `(0.65, 0.86)-(0.71, 0.96)` on the `challenging` overview; it does not match the bottom-center `refresh` button (offline score in the old ROI: `0.118`). +- The existing `RESET_BUTTON_ROI = ROI(0.64, 0.84, 0.73, 1.0)` is correct for the small control but cannot handle the `refresh` state. `ENTRY_REFRESH` matches the captured refresh page at `0.9856` in a bottom-center ROI. +- The production reset path must recognize either existing reset control before clicking, then use the existing confirmation matcher. No blind fallback click is needed. +- In the first long-run attempt, ticket 2 hit a second failure at `05:09:20`: after the close click, the next preparation check still saw `fleet_acquisition`; the subsequent `CHOOSE_FLEET` retry waited for an overlay that was no longer consistently present and timed out at `05:09:28`. +- The stability harness originally restarted the app to home after this error but did not reset the decisive chapter, so later tickets reused the stale `challenging` state and repeatedly failed the insufficient-fleet path. The harness now re-enters Ex-6, resets `challenging/refresh` to `refreshed`, and halts if that recovery cannot be verified. +- The production fix is to require a fresh second screenshot after a first-frame `fleet_acquisition` hit. If the second frame does not match, the controller discards the stale overlay result and continues normal map recognition. +- A run interrupted between tickets can leave the game in `challenging`; the corrected harness now performs the same reset-and-verify step before ticket 1 as well as after ticket errors. +- The final run reached a full-dock state. `reset_button` was recognized at `(0.684, 0.932)`, but two clicks produced no confirmation; clicking the central challenge button exposed the `舰船船坞已满` dialog. No ship destruction was authorized or performed. +- In the final run, ticket 2 recovery reached `reset_button` recognition but did not produce the confirmation dialog, so recovery correctly failed closed. The harness previously still entered its deadline-only expedition loop after `halted`; this is now fixed to write the report and exit immediately. + +## Decisive Preparation Return Fix +- `DecisiveBattlePreparationPage` is the concrete page used by decisive fleet scanning and fleet changes. +- Its inherited `BattlePreparationPage.go_back()` waited for generic `MapPage.is_current_page()`, which does not recognize the decisive map layout. +- Added a decisive-only `go_back()` override using `is_decisive_map_page`; generic campaign/exercise preparation navigation remains unchanged. +- The first rerun still timed out because `is_decisive_map_page()` itself used the stale `decisive_map_540p.png` template; the actual return screenshot scored `0.2687` against the `0.85` threshold. +- The existing `SIG_MAP_PAGE` pixel signature matched that same screenshot 5/5, so `is_decisive_map_page()` now uses `PixelChecker.check_signature` without adding a new asset. +- Offline verification after the final fix: `uv run pytest -q testing/ops` -> `115 passed`; compileall and selected pre-commit hooks passed. +- Final recovery-chain verification passed all 44 steps, including Case 3 map return/temporary leave and Case 4 resume stopping before battle. + +## Stability Run Attempt 2026-09-09 08:24 + +- The run's normal startup recovery successfully returned from the stale map overlay to the home page and navigated back to Ex-6. +- The live overview was `challenging`; the reset icon matched at the expected ROI and click coordinate `(0.684, 0.932)`. +- Two recognition-gated reset clicks left the overview unchanged and never exposed `confirm_1`. The failure screenshot does not show the full-dock dialog seen when the central `挑战中` button was previously clicked. +- This is a distinct unresolved reset-entry behavior: the click target is recognized, but the game ignores it in the current `challenging` state. The next diagnostic should inspect the challenge entry state/interaction rather than retrying the same reset click. + +## Confirm Exit Template ROI (2026-09-09) + +- `confirm_exit_720p.png` is a 526x273 cropped dialog template, not a full-screen image. +- The user-marked 1280x720 red box is `x=363..916, y=161..464`; the matching ROI uses the exclusive edge `(917, 465)`. +- `detect_decisive_overlay()` now searches `CONFIRM_EXIT` only inside `CONFIRM_EXIT_ROI`; fleet and advance overlay behavior is unchanged. +- The marked screenshot matched at confidence `0.9989` both before and after the ROI restriction. + +## Entry Status Template ROI (2026-09-09) + +- The four overview entry templates share the bottom-center status button location. +- The user-marked 1280x720 red box is `x=547..813, y=635..704`; `ENTRY_STATUS_ROI` uses the exclusive edge `(814, 705)`. +- The ROI is now used for overview-page recognition, post-chapter entry-status detection, and stage-clear return-to-overview checks. +- On the marked `挑战中` screenshot, `entry_challenging_540p.png` matches at `0.9951`; the other entry templates do not cross the recognition threshold. + +## Stage Number Alignment (2026-09-09) + +- `recognize_stage()` now returns `i + 1` for the first unfinished marker instead of the zero-based `i`. +- The returned values are now `1/2/3`, aligned with `DecisiveState.stage` and `EX-{chapter}-{stage}.yaml`; unknown chapters still return `0` as an error sentinel. +- Added coverage for first, second, and third/all-complete marker states. + +## Chapter Clear Stage Signal (2026-09-09) + +- Stage `3` now means the third subsection is active (the first two markers are complete and the third is not). +- All three markers complete now returns `None`, and `_handle_enter_map()` transitions to the existing `CHAPTER_CLEAR` phase without clicking the map-entry button. +- Unknown chapters still return `0` and are rejected by the entry handler. + +## Node Context Log (2026-09-09) + +- Existing node recognition only logged the bare DLL result (`识别决战节点: A`). +- Added a business-level log after a real node is accepted: `当前进入为章节 {chapter} 小节 {stage} 的 {node} 列`. +- The log is emitted only after `CHOOSE_FLEET` is excluded and the node is assigned to runtime state. + +## Temporary Leave Recovery Flow (2026-09-09) + +- Production `DecisiveController.run()` starts each invocation with a fresh `DecisiveState`, `_resume_mode=True`, and `_has_chosen_fleet=False`; it does not persist a controller/state object across process restarts. +- `_execute_leave()` only opens the exit dialog and clicks the leave action. The controller then returns `DecisiveResult.LEAVE`; the server task records a successful `leave` result and stops the round. It does not automatically re-enter the decisive map. +- On a later invocation, `_handle_enter_map()` re-enters the currently active subsection from the overview (`CHALLENGING` or `REFRESHED`), then `WAITING_FOR_MAP` checks `USE_LAST_FLEET` (3-second stabilization plus three ROI matches), `ADVANCE_CHOICE` (the annotated ROI variants), fleet acquisition, and finally the decisive map signature. +- A temporary-leave resume with no advance popup therefore routes directly to `PREPARE_COMBAT`. With `state.node == 'U'`, `_handle_prepare_combat()` re-anchors once through the orange ship marker and DLL; the result is logged as the current chapter/subsection/node. Later node movement is logical/map-data based. +- `_resume_mode` then scans the current formation and available ships through `check_fleet()` only when `state.is_begin()` is false. For stage 1 node A, `state.is_begin()` is true and the handler clears `_resume_mode` before the scan; this is a special first-node path and is the main state distinction to review before a recovery redesign. +- The stability E2E case is not the production task contract: it manually calls `_prepare_entry_state()`, reuses one controller, resets only state for retreat, and continues after leave. Its Case 4 verifies the no-popup path and stops on preparation without clicking sortie; the server API itself stops at `LEAVE`. +- Stability evidence: ticket 1 repeatedly re-entered after leave and reached stage 3 node A with `恢复模式: 扫描当前舰队`; the same run later failed in post-combat `WAITING_FOR_MAP`, which is a separate transition-recognition issue. + +## Post-combat Node and Overlay Recognition Audit (2026-09-09) + +- Node recognition is already a UI-level method, but its responsibilities are mixed: `get_ship_icon_pos()` performs HSV marker localization, `get_ship_icon_pos_with_retry()` waits up to 10 seconds, and `recognize_node()` stabilizes the marker, crops the vertical column, calls the DLL, retries DLL failure, and may return the `CHOOSE_FLEET` sentinel. +- The only production caller of `recognize_node()` is `_handle_prepare_combat()` when `state.node == 'U'`. This means fresh entry/resume can anchor the current node, but post-combat does not re-anchor. +- After combat, `_handle_node_result()` checks the map-data terminal condition, then predicts the next base letter with `chr(ord(current_node) + 1)`, stores `_advance_source_node`, and polls `detect_decisive_phase()` every 0.5 seconds for up to 15 seconds. It does not use the graph to assign the next node ID or call node recognition. +- The post-combat poll already recognizes `ADVANCE_CHOICE` and `CHOOSE_FLEET`, but through the generic entry detector rather than a dedicated post-combat method. A linear next node reaches `PREPARE_COMBAT` through the map-page fallback; a branch reaches `ADVANCE_CHOICE` first. +- Before the ROI integration, `FLEET_ACQUISITION` used `fleet_acq_720p.png` as a full-screen template at confidence `0.70`; unlike `ADVANCE_CHOICE`, it had no ROI restriction. The first overlay path performed a fresh second-screen confirmation, but the map-page fallback's second overlay check routed `FLEET_ACQUISITION` directly to `CHOOSE_FLEET` without the same fresh confirmation. +- `ADVANCE_CHOICE` recognition does use route-aware ROI selection: unknown initial node checks both the two-card and three-card ROIs; a known post-combat source node uses `MapData.get_leftmost_choices()` to choose the expected ROI. A route with one successor falls back to both ROI variants even though no popup is expected. +- The user's four facts align with the intended state machine: first entry expects a possible advance popup before the first node is known; leave-resume expects no popup and must anchor the current node; combat starts only after a node is known; post-combat must resolve advance choice before fleet acquisition or the next preparation page. + +## Fleet Overlay Context Gate (2026-09-10) + +- Kept `_resume_mode` focused on its existing responsibility: scanning the current formation during recovery. It is not reused as the fleet-overlay switch. +- Added `_fleet_overlay_enabled` as a separate runtime context. New entry, retreat re-entry, and stage transitions set it to `True`; `_execute_leave()` sets it to `False` for same-controller temporary-leave recovery; a non-terminal `NODE_RESULT` sets it back to `True`; terminal results leave it disabled until the next stage begins. +- Threaded the gate through `wait_for_entry_phase()` and `detect_decisive_phase()`. Only temporary-leave re-entry can now reach the map/`PREPARE_COMBAT` without treating `FLEET_ACQUISITION` as an entry condition; normal new entry still keeps fleet detection enabled. +- The effective first-wait gate is stricter than the context flag: before an advance card has been selected, fleet matching is skipped; after `ADVANCE_CHOICE` (`_skip_advance_choice`) or after a post-combat source node exists, fleet matching is enabled. A no-advance map fallback with no source node is classified as temporary-leave recovery and turns the flag off. +- The gate also filters `detect_decisive_overlay()` so a disabled fleet overlay cannot mask an `ADVANCE_CHOICE` result. The fleet ROI/template itself was intentionally left unchanged pending the user's annotated ROI. +- Verification: focused decisive tests `29 passed`; full `testing/ops` suite `133 passed`; compileall and `git diff --check` passed. + +## Fleet Overlay ROI and Template (2026-09-10) + +- The red title box in the marked 1280x720 screenshot is `(x1=494, y1=38, x2=799, y2=107)` using exclusive lower-right coordinates. +- The resulting ROI is `305x69`. The no-red screenshot was cropped into `autowsgr/data/images/decisive/fleet_acq_720p.png`. +- The runtime matching ROI is intentionally expanded by 1px on every side to `(x1=493, y1=37, x2=800, y2=108)`; the template remains the tighter `305x69` crop. +- The previous `fleet_acq_720p.png` was a `505x62` crop of the bottom refresh/close buttons; it was replaced with the requested title template. +- Applied `FLEET_ACQUISITION_ROI` to overlay detection, `is_fleet_acquisition()`, and `wait_for_overlay(FLEET_ACQUISITION)` so no fleet path searches the full screen. +- Direct OpenCV validation on the source screenshot returned score `1.0` at `(494, 38)`. +- Verification after ROI integration: focused decisive tests `30 passed`; full `testing/ops` suite `134 passed`; compileall and `git diff --check` passed. + +## Decisive ROI Padding Audit (2026-09-10) + +- Added `ROI.expand_pixels(width, height, padding=1)` and applied one-pixel padding to every fixed decisive template ROI: use-last fleet, fleet name, fleet acquisition, confirm exit, two/three-card advance choices, entry status, and reset controls. +- The runtime fleet ROI remains the padded `(493,37)-(800,108)` around the exact `305x69` template; the other ROIs now use the same helper instead of hand-written unpadded bounds. +- Template-fit audit at 1280x720: all decisive templates fit their padded ROI except legacy `entry_cant_fight_540p.png`, which scales to about `631x71` while the shared entry-status ROI is `269x72`. This is a source-template/ROI contract mismatch, not a one-pixel boundary issue; widening that fixed ROI would defeat the user's annotated location. + +## Fleet Acquisition Page Flow Audit (2026-09-10) + +- `CHOOSE_FLEET` enters `_handle_choose_fleet()`, which now starts with `_has_chosen_fleet=False` and only commits it after a non-empty purchase decision and successful overlay close. +- The controller waits for the title ROI/template to remain detectable, then holds a stable screenshot for 1 second by sampling every 0.25 seconds. +- Fleet OCR reads the resource score, all visible costs, and eligible card names. Up to three OCR attempts are made; after an empty result it verifies the overlay is still open before retrying. +- The decision layer selects purchases from current score, current fleet count, configured level1/level2 priorities, and first-node rules. If no purchase is selected, it refreshes the offer list once and repeats OCR/selection. +- Each selected card is clicked at its fixed normalized card position; non-skill purchases are added to `state.ships`. The phase is set to `PREPARE_COMBAT` before the close action. +- `close_fleet_overlay()` clicks the fixed close button and polls every 0.2 seconds for up to 5 seconds. It considers the overlay closed when the fleet title template is no longer detected inside the fleet ROI, then waits an additional 1.5 seconds for the semi-transparent overlay/map transition to settle. +- Close failure changes the phase directly to `RETREAT`; it no longer buys an arbitrary first OCR selection. +- An empty purchase decision closes the overlay, sets a one-shot force-current-fleet-scan flag, and enters `PREPARE_COMBAT`. The preparation path then uses existing fleet data to distinguish enough ships from insufficient ships and lets `should_retreat()` decide. +- After a successful close, the next state-machine iteration enters `PREPARE_COMBAT`, which performs another overlay check before formation. The close confirmation still uses title disappearance plus settle time, not a separate formation/sortie ROI. + +## Decisive Formation and Retreat Navigation Audit (2026-09-10) + +- Purchase audit (2026-09-12): `DecisiveLogic.choose_ships()` only accepts OCR names that exactly match configured `level1`/`level2` ships. In the latest node-E log, score `5` was available, but the first OCR pass returned `549`, `加里波第`, and `防空伞`, and the refreshed pass returned only `549`; none matched the current chapter-6 YAML, so `选择购买: []` is explained by the OCR/config contract rather than a configured backup being skipped. +- Formation audit (2026-09-12): an empty purchase sets `_force_fleet_scan=True`, then `_handle_prepare_combat()` calls `DecisiveMapController.check_fleet()`. `check_fleet()` always enters formation, detects the current fleet, and then always clicks slot 0 to open the ship pool before returning to the map. This is the direct cause of the redundant ship-pool visit even when the current formation is already sufficient. +- After that scan, `_handle_prepare_combat()` enters formation again and compares `state.fleet` with `get_best_fleet()`. The node-E log shows four existing ships were recognized and the target formation matched; the unnecessary pool visit happened inside `check_fleet()`, before the sufficiency decision. + +- Implemented boundary: `check_fleet()` returns after a non-empty formation scan, stays on the preparation page, and does not click slot 0 or OCR the ship pool. An empty formation scans the pool and also stays on the preparation page for immediate replacement. +- Preparation recovery now merges scanned/current ships into `state.ships` instead of replacing the accumulated per-round set. Retreat still calls `state.reset()`. +- Regression coverage confirms a non-empty formation skips `click_ship_slot(0)` and ship-list OCR. +- `full_recovery_check=True` now forces the pool scan even when the current formation is non-empty, so an abnormal restart rebuilds context from both formation and pool. +- Real-device pass `logs/e2e_tools/decisive/20260913_001508` completed chapter 6 stages 1-3 with `chapter_clear`; the key empty-formation log shows pool OCR followed by immediate formation replacement without returning to the map. +- The same pass proves stage 3 did fight: it has `小关 3` combat/result pairs for A through J and ends with `小关 3 终止节点 J 已到达`. However, after stage 2 clear the first stage-3 route log was `source=A choices=['B1']`, with no live node recognition. +- Root cause: `_handle_stage_clear()` assigned `state.node='A'` before entering the next subsection. This bypassed the intended `U -> recognize_node() -> A` anchor and made the entry route use A instead of map node 0. +- Fixed the boundary to reset the next subsection to `U`; the next entry now selects from source 0 and recognizes the live first node in preparation. +- Current real overview screenshot `debug/current_decisive_overview.png` shows the first two yellow completion badges and the third subsection at `0/50`. The old three-point logic incorrectly saw all three common node-glow pixels as completed and returned `None`. +- The corrected stage rule treats the three points as node existence: `100 -> stage 1`, `110 -> stage 2`, `111 -> stage 3 or complete`. For `111`, `entry_refresh` means complete; `entry_challenging` plus the reset-button ROI means stage 3 is still active. +- Direct recognition of the real screenshot now returns `STAGE=3`; offline verification passed with decisive tests `50 passed` and full `testing/ops` `154 passed`. +- Follow-up real-device run `logs/e2e_tools/decisive/20260913_012346` recognized `第 3 小节正在进行`, re-anchored `U -> A`, fought A-J, and reached `小关 3 通关`/`chapter_clear` with no E2E failures. +- Map to formation in the normal combat path is `_handle_prepare_combat()` -> `DecisiveMapController.enter_formation()` -> `click_and_wait_for_page(CLICK_FORMATION, BattlePreparationPage.is_current_page)`. The formation page is then checked by the fixed `FLEET_NAME_ROI` title template, with one map-return retry if the title is not detected. +- The same `enter_formation()` is used by `DecisiveMapController.check_fleet()` when recovery scanning needs to read the current formation and available ships. +- Explicit preparation-page back is `DecisiveBattlePreparationPage.go_back()`, which clicks `CLICK_BACK` and waits on the decisive map pixel signature. This is used by formation scanning/E2E return paths. +- Retreat/leave uses a different reverse path: `_execute_retreat()` -> `open_retreat_dialog()` -> `go_to_map_page()` -> raw back click if the decisive map signature is absent -> click `CLICK_RETREAT_BUTTON` -> wait for `CONFIRM_EXIT` -> `confirm_retreat()` clicks `CLICK_RETREAT_CONFIRM`. +- Therefore `open_retreat_dialog()` owns the “编队页回地图再打开撤退确认” behavior; `DecisiveBattlePreparationPage.go_back()` is the explicit preparation-page return helper and is not called by the retreat dialog path. + +## Formation Failure Handling Audit (2026-09-10) + +- If the formation click itself does not reach the preparation page, `click_and_wait_for_page()` raises `NavigationError`; `enter_formation()` does not catch it, so `DecisiveController.run()` catches it at the top level and returns `DecisiveResult.ERROR`. +- If the preparation page is reached but the decisive fleet title is not recognized, `enter_formation()` returns to the map and retries the formation click once. A second title failure raises `TimeoutError`, which also becomes task `ERROR`. +- Fleet replacement, repair, damage detection, and manual-repair navigation exceptions all propagate out of `_handle_prepare_combat()` and become `ERROR`; there is no automatic return-to-map/retreat recovery around these operations. +- `BattlePreparationPage.start_battle()` only clicks the sortie button. The handler sleeps one second and advances to `IN_COMBAT` without verifying that the combat page was reached, so a sortie click failure is detected later by the combat engine rather than at the preparation boundary. +- The only dedicated formation failure recovery currently implemented is the single title-recognition retry; a general exception cleanup/retreat path is still missing. + +## Formation Error Recovery Boundary (2026-09-10) + +- Production `DecisiveController.run()` catches formation and other execution exceptions, logs them, and returns `DecisiveResult.ERROR`; it does not call `restart_game()` or reset the decisive chapter. +- The server decisive task records the error result and stops the task at `result.value in {'leave', 'error'}`; no SL/home/reset recovery is performed there. +- The requested restart-and-reset behavior exists in the stability E2E case only: its exception handler calls `_restart_to_home()` and then `_reset_after_restart()`, which resets the chapter when the entry status is `REFRESH` and preserves `CHALLENGING` progress otherwise. +- Therefore a future production exception policy should reuse the startup/restart operations at the task boundary, rather than adding page-specific recovery branches inside formation operations. + +## Decisive Task Error Retry (2026-09-10) + +- Added a task-boundary retry budget of 3 total controller attempts for `DecisiveResult.ERROR`. +- Every failed attempt runs `restart_game()` plus `ensure_game_ready()` before the next attempt; the final exhausted attempt also leaves the game restarted/initialized before returning task failure. +- `LEAVE` and successful results are not retried. Intermediate ERROR attempts are not added to the task result list; only the final round result is reported. +- Targeted server validation: 4 decisive retry/leave tests passed. +- Full `testing/server/test_task_routes.py` remains blocked by unrelated existing test-environment failures: pytest temp directory permission denied and non-decisive fixtures missing `set_repairing`. + +## SL Full Recovery Check (2026-09-10) + +- Retry attempts after the first ERROR call `controller.run(full_recovery_check=True)`. +- Full recovery keeps fleet-overlay detection enabled before ADVANCE resolution; a missing ADVANCE popup no longer disables fleet detection in this context. +- The existing `U` node path re-identifies the current node, and `_force_fleet_scan=True` makes preparation scan the current formation/available ships even at the first node. +- Only after node recognition, fleet-overlay handling, current-fleet scan, sufficiency check, formation preparation, and repair/damage checks does the handler clear the full-recovery flag and click sortie. + +## 通用战斗链路审查(2026-09-10) + +- 决战 `_handle_prepare_combat()` 在编队、维修和血量检测后调用通用 `BattlePreparationPage.start_battle()`;该方法只点击固定的 `CLICK_START_BATTLE` 坐标,不做出征按钮消失或战斗页到达确认。决战处理器随后固定 `sleep(1.0)`,直接把阶段设为 `IN_COMBAT`。 +- `CombatEngine` 从 `START_FIGHT` 过渡态开始,首轮候选为 `SPOT_ENEMY_SUCCESS`、`FORMATION`、`FIGHT_PERIOD` 和 `DOCK_FULL`,通过模板/像素签名轮询识别;`START_FIGHT` 自身没有视觉签名。 +- 首轮识别的最大等待时间由候选中 `FIGHT_PERIOD` 的 `30s` 决定。超时后 `_try_recovery()` 先固定等待 `3s`,只检查当前计划终态;决战终态是 `RESULT`。检查不到终态就返回 `SL`,随后 `fight()` 直接调用 `restart_game()`。这会把出征后未进入战斗、战斗页过渡帧或识别漏检都压缩成同一个 `SL` 结果。 +- 正常通用战斗顺序为:索敌成功 → 敌方编成/阵型识别与规则决策 → 进入战斗或撤退/迂回 → 阵型 → 战斗进行 → 夜战提示(可能跳过)→ 战果页 → 评级、结算血量、MVP → 点击结算页继续。决战使用 `SINGLE` 转移图,战果页是引擎终态。 +- `FIGHT_PERIOD` 后等待候选包含 `NIGHT_PROMPT`(默认 `150s`)和 `RESULT`;因此没有夜战弹窗时,战果识别最多要等夜战候选的超时时间,不是固定 15 秒。 +- 战果页 `_handle_result()` 已经采集评级、血量、MVP,并调用 `_click_result_until_closed()`:第一下点击把 `RESULT` 推进到 `EXP_SETTLEMENT`。决战的终态仍定义为 `RESULT`,引擎在确认经验页后返回,外层 `_handle_combat()` 的第二次 `click_result()` 是关闭经验结算页,不是简单重复;但第二次点击后只固定等待 `0.3s`,没有验证地图/overlay 到达。 +- 当前确认边界更具体:`_click_result_until_closed()` 只在第一次点击后的轮询中识别 `EXP_SETTLEMENT`;虽然 `_result_successors()` 支持 `GET_SHIP`,但决战引擎在 `RESULT` 终态确认经验页后立即结束,外层第二次点击没有调用 `identify_current()`,因此不会确认第二次点击是否进入 `GET_SHIP`,也不会在这里捕获掉落。 + +## 决战战斗与掉落收口边界(2026-09-10) + +- 决战没有独立的战斗引擎;`ops/decisive/handlers.py` 直接导入通用 `run_combat()`,以 `CombatPlan(mode=CombatMode.DECISIVE)` 运行战斗。 +- 决战的专属逻辑在战果之后:通用引擎确认 `RESULT -> EXP_SETTLEMENT` 后返回,决战外层再点击一次;非终止节点进入 `NODE_RESULT`,终止节点进入 `confirm_stage_clear()`。 +- `confirm_stage_clear()` 在 `map_controller.py` 中额外执行两次强制确认,然后扫描 `GET_SHIP/GET_ITEM` 模板,逐个 OCR 掉落并点击关闭,最后确认回到决战入口页。这是决战看起来“自己处理掉落”的原因,不是独立战斗实现。 +- 成功日志 `logs/e2e_tools/decisive/20260909_002610/autowsgr_2026-09-09.log` 证明了该补偿链:节点 J 战果成功后进入小关通关,随后两次 `confirm_*`,再连续收集 10 个掉落,最后回到决战入口页。 + +## 普通战与决战结算边界对比(2026-09-10) + +- 普通战/活动战使用 `NormalFightRunner._do_combat()` 调用同一个 `run_combat()`,但 runner 的 `_handle_result()` 只处理 `DOCK_FULL` 和记录结果,不在外层额外点击结算按钮。 +- 普通战/活动战的 `CombatMode` 终态分别是 `MAP_PAGE`/`EVENT_MAP_PAGE`。通用引擎会继续处理 `RESULT -> EXP_SETTLEMENT -> GET_SHIP/终态`,包括掉落 OCR 和掉落页关闭。 +- 决战的 `CombatMode.DECISIVE` 终态被配置为 `RESULT`,因此通用引擎在确认经验页后结束;决战外层再裸点一次,并在小关终点用 `confirm_stage_clear()` 自己处理确认弹窗和掉落。 +- 因此“普通战也是这么做的吗”的答案是否定的:共享的是战斗引擎,结算收口契约不同。决战把通用引擎的终态提前截在 `RESULT`,再由决战层补偿后续页面。 +- 用户随后确认决战由上层负责从经验结算页继续点击是正确边界;此前临时添加的错误 TODO 已移除。 + +## 战后点击前正向识别保护(2026-09-10) + +- `select_advance_card()` 已在点击卡片前强制 `wait_for_overlay(ADVANCE_CHOICE)`;本次保留该保护。 +- 发现共享 `DecisiveMapController.enter_formation()` 只记录地图/准备页识别结果,没有用地图识别结果阻止未知页面点击。该入口同时被正常出征和恢复扫描调用,属于共享点击边界。 +- 已在 `enter_formation()` 点击前强制 `is_decisive_map_page(screen)`;未识别到决战地图时抛出 `TimeoutError`,不会调用 `click_and_wait_for_page()`。 +- 新增未知页面拒绝测试;验证 `testing/ops` `139 passed`,`git diff --check` 通过。 + +## 经验结算识别与成功返回边界(2026-09-10) + +- 原全屏 MVP 模板判据已被用户提供的固定 ROI OCR 方案替代;运行时不再使用全屏 `result_page_540p.png` 判断经验页。 +- `_click_result_until_closed()` 点击后每 `0.3s` 轮询一次,单次最多 4 帧;命中 `EXP_SETTLEMENT` 才会记录后继状态。若持续无法识别,当前实现只记录 debug 并返回,不抛异常。 +- 决战的通用终态是 `RESULT`。因此在后继识别失败、内部 phase 仍为 `RESULT` 时,`_make_decision()` 仍可能把本轮转成 `FIGHT_END`,`CombatEngine` 返回 `OPERATION_SUCCESS`;这会让上层误以为可以点击经验页后的按钮。 +- `OPERATION_SUCCESS` 后决战上层第二次点击也没有验证经验页消失;非终点会进入地图轮询,终点则先按地图数据进入 `STAGE_CLEAR`。因此“点击未生效”时,终点路径尤其可能在未确认页面上继续执行确认点击。 +- 决战随后 `_handle_node_result()` 才以 `0.5s` 间隔轮询 `ADVANCE_CHOICE`、战备浮窗和地图阶段,最长 `15s`;这段等待不是战斗结果识别,而是战果点击后的地图/弹窗收口。 +- 稳定性报告中的 tickets 3/4/8/9 失败帧在代码上对应战斗识别超时链:30 秒未命中候选 → `SL` → 决战继续进入 `NODE_RESULT` → 后续地图等待超时。失败帧本身命中 `SPOT_ENEMY_SUCCESS` 只能说明超时后的残留页面,不能证明超时瞬间的页面。 + +## 经验结算 ROI OCR 判据(2026-09-10) + +- 根据用户提供的干净经验结算截图,将 1280x720 顶部 `数字 + Exp` 区域定义为 `EXP_SETTLEMENT_ROI = ROI(272/1280, 4/720, 388/1280, 43/720).expand_pixels(1280, 720)`。 +- 运行时 OCR 只裁切该 ROI,允许字符为数字和 `EXP`,按 OCR 横向顺序拼接、去除空白并大写化后,必须完整匹配 `\\d+EXP`。 +- 经验页必须连续 3 帧 OCR 命中才算稳定识别;确认后额外等待 1 秒,再允许结算点击继续。 +- `EXP_SETTLEMENT` 不再配置全屏 `result_page_540p.png` 模板;`wait_for_phase()` 和结算点击复检走运行时 ROI OCR,旧静态识别接口跳过该状态,避免全屏回退。 +- 组合测试首次运行受 pytest 临时目录权限影响(5 个既有 event-map 测试 setup error);经验专项修正后 `25 passed`,`testing/ops` `139 passed`,compileall 和 diff check 通过。 +- 真实 EasyOCR 验证:1x 返回 `200 (1.000)` + `Exp (0.999)`;2x 返回 `200 (1.000)` + `Exp (0.488)`;4x 返回 `200 (0.960)` + `EXP (0.596)`;8x 返回 `200 (1.000)` + `EXp (0.563)`。直接调用生产 matcher 连续三次返回 `[False, False, True]`,稳定计数行为符合预期。 + +## 经验结算增量 OCR 修正(2026-09-10) + +- 用户明确要求取消固定三帧完整字符串判定:战果点击后立即进入循环,每 `0.75s` OCR 一次。 +- OCR 结果按允许字符增量累加到列表:可先累积 `E`,后续追加 `X`、`P`;同时必须已经识别到数字。列表累积出 `EXP` 且从点击开始经过至少 `1.5s` 后,才确认经验页并额外等待 1 秒。 +- 空白可忽略;除 `0123456789EXP`(大小写归一化)之外的字符会使本次 OCR 结果被拒绝,不会被静默过滤后误通过。 +- 真实生产循环实测同一截图输出 `200EXP`:约 `0.05s/0.85s/1.64s` 三次累积,第三次确认成功并在 1 秒后返回;专项测试 `27 passed`,`testing/ops` `139 passed`。 + +## 经验结算一致结果与超时边界(2026-09-10) + +- 经验页成功条件已进一步明确为:战果点击后每 `0.75s` OCR;每次形成一个完整 `数字+EXP` 结果并追加到历史 list;最近三个结果必须一致,且总耗时 `>1.5s`。 +- 超过 `10s` 仍未得到三个一致结果时,记录错误日志“未能识别到经验结算页”并抛出 `TimeoutError`,进入上层 ERROR 流程。 +- 真实截图实测累积结果为 `['200EXP', '200EXP', '200EXP']`,约 `1.63s` 达成一致,随后等待 1 秒。 +- 单个结果内部结构已固定为四格 `['2', '0', '0', 'EXP']`;三个结果逐项比较,真实循环日志确认三组四格完全一致,约 `1.96s` 后等待 1 秒。 + +## 经验结算结果 list 语义修正(2026-09-10) + +- 用户澄清:历史 list 的三个槽位分别保存完整数字,例如 `['200', '200', '200']`;`EXP` 是固定格式条件,不进入历史 list。 +- 已修正实现和真实验证:日志结果为 `['200', '200', '200']`,约 `1.75s` 达成一致,随后等待 1 秒。 diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..6f519933 --- /dev/null +++ b/progress.md @@ -0,0 +1,597 @@ +# Progress Log + +Task ID: 20260907-autowsgr-decisive-debug-6e3a +Task Status: in_progress +Next Step: Extract explicit node re-anchoring, separate post-combat overlay recognition, add focused tests, then run device verification. + +### 2026-09-09 04:30: refresh-state reset recovery +- Diagnosed the stability-run loop: `reset_chapter()` only searched the `reset_button.png` ROI, while the real `refresh` overview presents the existing `entry_refresh_540p.png` as a large bottom-center `重置关卡` button. +- Added `RESET_ENTRY_ROI` and a staged `ENTRY_REFRESH` fallback in `autowsgr/ui/decisive/battle_page.py`; the original right-side `RESET_BUTTON_ROI` path remains first. +- Added `test_reset_chapter_falls_back_to_refresh_entry` to `testing/ops/test_decisive_unit.py`. +- Offline reset tests: `3 passed`. +- Real-device smoke: `BEFORE refresh` → `识别到重置入口: entry_refresh` → `confirm_1` → `AFTER refreshed`; no ticket was started. +- First stability run: ticket 1 completed all three stages with 3 leaves and 1 retreat; ticket 2 hit a stale fleet-overlay/state-machine timeout at `05:09:28`, then the old harness restarted home without resetting the chapter and contaminated later tickets. The run was stopped at `05:14`. +- Added harness recovery: restart home → re-enter Ex-6 → reset `challenging/refresh` → verify `refreshed`; stop if recovery verification fails. +- Added production stale-frame guard: `fleet_acquisition` must match again on a fresh screenshot before entering OCR; added an offline regression. +- Added start-of-run chapter reset: the harness now verifies `refreshed` before ticket 1, covering an interrupted prior process. +- Final run: ticket 1 completed; ticket 2 hit a `WAITING_FOR_MAP` timeout and reset recovery recognized `reset_button` but did not open `confirm_1`, so the run was stopped safely. Added immediate report/exit when recovery is halted instead of waiting for the deadline-only expedition loop. +- Reports written: `logs/e2e_tools/decisive_stability/20260909_053232/stability_report.md` and `debug_report.md`. +- User reports the ship depot has been cleared and authorizes resuming the remaining 9-ticket stability run. +- Added `--stop-after-tickets` so the run exits immediately after the requested count instead of waiting for the next day's 08:00. + +### 2026-09-08: decisive preparation return checker +- Confirmed the prior real-device timeout was after a successful back click; the screenshot was already the decisive map. +- Added `DecisiveBattlePreparationPage.go_back()` using `is_decisive_map_page` instead of the generic tabbed-map checker. +- Added a focused regression asserting the decisive checker is passed to `click_and_wait_for_page`. +- Verification: focused decisive tests `12 passed`; full `testing/ops` `114 passed`; compileall passed; selected pre-commit passed. +- Next: rerun the existing four-case real-device recovery chain and inspect whether Case 3 reaches temporary leave and Case 4. + +### 2026-09-08 22:17: first rerun after decisive go_back override +- Case 1, Case 2, and Case 3 formation completed. +- Case 3 `编队完成回到地图` still timed out; the back click had already returned to the decisive map. +- Offline matching of the failure screenshot showed `decisive_map_540p.png` confidence `0.2687`, below the `0.85` threshold. + +### 2026-09-08: final decisive map recognition fix +- Replaced the stale template check inside `is_decisive_map_page()` with the existing `SIG_MAP_PAGE` pixel signature. +- Added positive/negative unit coverage for the map signature. +- Verification: focused decisive tests `13 passed`; full `testing/ops` `115 passed`; compileall and selected pre-commit passed. + +### 2026-09-08 22:27: final real-device recovery-chain +- Automatic reset, Case 1 retreat, Case 2 mocked one-ship retreat, Case 3 formation/return/temporary leave, and Case 4 resume all passed. +- Result: `44 steps, 0 failures`; Case 4 stopped on the preparation page without calling `start_battle`. +- E2E log directory: `logs/e2e_tools/decisive/20260908_222708`. + +### 2026-09-08 22:35: committed-version real-device recovery-chain +- Re-ran the same four-case chain from the committed code checkpoint `78b77de`. +- Result: `44 steps, 0 failures`; Case 3 map return/temporary leave and Case 4 resume passed again. +- Case 4 ended on the preparation page without starting battle; cleanup returned the device to the main page. +- E2E log directory: `logs/e2e_tools/decisive/20260908_223501`. +- Code checkpoint: `78b77de` (`fix(decisive): recognize map after preparation return`). + +## Session: 2026-09-08 + +### Current Status +- **Phase:** 1 - Requirements & Discovery +- **Started:** 2026-09-08 + +### Actions Taken +- Confirmed the coordination root and the independent AutoWSGR repository root. +- Read the parent and AutoWSGR repository instructions. +- Preserved the shared `ShiinaKuroko` checkout and its existing user changes. +- Created `C:\ShiinaKuroko\01.Project\AutoWSGR\.worktrees\20260907-autowsgr-decisive-debug-6e3a` from `ShiinaKuroko`. +- Created branch `codex/20260907-autowsgr-decisive-debug-6e3a` at `c5a464c`. +- Bound the worktree to the current Agent identity and initialized task-scoped planning files. +- The first template patch did not match the generated file and made no changes; the generated files were reread before recreating them. +- Restored the committed `tools/e2e` package from `codex/20260830-autowsgr-upgrade` without touching that dirty source worktree. +- Adapted the E2E framework to the current launcher/context/UI APIs and added `tools/e2e/cases/decisive.py`. +- Fixed global E2E flag parsing so the documented `screenshot --no-launch` invocation works. +- Ran `uv sync --all-groups` to install the repository lockfile environment. +- Read the GUI system plan `AutoWSGR-GUI/resource/system_daily_plans/decisive-决战第6章.yaml` without modifying the GUI repository. +- Applied its chapter-6 shared fields to `usersettings.yaml`: one round, quick repair enabled, the six level1 ships, and the full level2 list. +- Retained the backend-only flagship priority, repair level, full-destroy, and useful-skill settings because the GUI source contract does not define them. +- Started one real-device chapter-6 E2E with the imported default configuration, then stopped it at the user's request. + +### Test Results +| Test | Expected | Actual | Status | +|------|----------|--------|--------| +| `git rev-parse --show-toplevel` | AutoWSGR task worktree root | `C:/ShiinaKuroko/01.Project/AutoWSGR/.worktrees/20260907-autowsgr-decisive-debug-6e3a` | Pass | +| `git branch --show-current` | New decisive debug branch | `codex/20260907-autowsgr-decisive-debug-6e3a` | Pass | +| `git rev-parse HEAD` | Current `ShiinaKuroko` base | `c5a464c7719bea74a7a26079b9656644237ff8cb` | Pass | +| Agent binding verification | Current Agent owns the worktree | Binding verified | Pass | +| `uv run pytest -q testing/ops/test_decisive_unit.py` | Existing decisive unit regression remains green | `1 passed` | Pass | +| `uv run python tools/e2e/run.py --list` | Copied tool discovers cases | `decisive` and existing cases listed | Pass | +| `uv run python tools/e2e/run.py --serial 127.0.0.1:16384 screenshot --no-launch` | Device/screenshot/page-recognition chain works | Connected at 1280x720, recognized `主页面`, exit 0 | Pass | +| `uv run python tools/e2e/run.py --serial 127.0.0.1:16384 --with-ocr decisive --times 1` | One real decisive round completes | Reached `CHOOSE_FLEET`, timed out waiting for `fleet_acquisition`, result `ERROR`, exit 1 | Root-cause evidence | +| GUI plan to backend config mapping | Shared fields are valid for `DecisiveConfig` | Applied to `usersettings.yaml`; no device run after the config update | Pass | +| Offline GUI-to-backend content comparison | Select the GUI system plan whose YAML chapter is 6 and compare shared fields | `chapter=6`, `rounds=1`, `level1=6`, `level2=21`, quick repair enabled | Pass | +| Chapter-6 real-device E2E | Reach and observe the configured decisive path | Passed fleet acquisition OCR and selected `U-1405` plus `鹦鹉螺`; reached stage 1 node A preparation before user interruption | Interrupted | + +### Errors +| Error | Resolution | +|-------|------------| +| Initial planning patch did not match the generated UTF-8 BOM template | Reread the generated files and recreated only the task-scoped planning files; no source files were involved. | +| Direct system Python lacked project dependencies | Installed the locked environment with `uv sync --all-groups`. | +| First screenshot command put `--no-launch` after the case and was rejected | Fixed the argument splitter, then reran the documented command successfully. | +| Cleanup called missing `Launcher.disconnect()` | Changed cleanup to disconnect `launcher.ctrl`; screenshot E2E then passed. | +| Decisive E2E timed out on `fleet_acquisition` | Stopped further device runs pending the user's configuration. | +| First offline comparison selected the first decisive YAML glob result (chapter 1) | Selected the source by parsed `chapter == 6` and reran the comparison successfully. | +| User interrupted the chapter-6 E2E before final result/cleanup | Stopped all further device actions; final result and cleanup state remain unverified. | + +### Recognition-gated entry fix +- Added the tools E2E `recovery-chain` scenario implementing the four requested cases; the last case verifies preparation-page readiness without calling `start_battle`. +- Applied the clarified three-path flow: first entry and same-task retreat can recognize `ADVANCE_CHOICE`; leave/resume can proceed without it. +- After card confirmation, route through fresh phase recognition instead of forcing `CHOOSE_FLEET`. +- Read the actual chapter-6 log: the first run bought a fleet, found insufficient state, retreated, and the interrupted/restarted path lacked a current-fleet scan. +- The attempted `_use_last_fleet_attempts` fallback was reverted after the user clarified this is an in-task retreat/leave path, not a task restart through the “use last fleet” entry. +- Removed the post-entry fixed delay in `autowsgr/ops/decisive/handlers.py`. +- Replaced the old ship-icon/no-overlay guess with staged recognition: wait for `USE_LAST_FLEET`, then `ADVANCE_CHOICE`, then accept only a positive fleet overlay or map-page match; a confirmed map page routes directly to `PREPARE_COMBAT`. +- Added an `ADVANCE_CHOICE` recognition gate inside `select_advance_card()`. +- Updated `testing/ops/test_decisive_unit.py` with no-delay, no-premature-fallback, and click-gating checks. + +| `uv run pytest -q testing/ops/test_decisive_unit.py` | Verify recognition-gated entry, clicks, and post-choice routing | `4 passed` | Pass | +| `uv run python -m compileall -q autowsgr/ops/decisive autowsgr/ui/decisive testing/ops/test_decisive_unit.py` | Compile changed modules | Exit 0 | Pass | +| `git diff --check` | No whitespace errors | Exit 0 | Pass | +| `uv run python -m compileall -q tools/e2e` | Compile the recovery-chain E2E case | Exit 0 | Pass | +| `uv run python tools/e2e/run.py --list` | Discover the updated E2E package | `decisive` listed | Pass | + +### Map-condition clarification +- Confirmed the advance-card gate is reached only from `DecisivePhase.ADVANCE_CHOICE`, which is set by positive overlay recognition. +- Confirmed terminal nodes route through `MapData.is_stage_end()` to `STAGE_CLEAR`; resume/no-popup paths do not invoke `select_advance_card()`. +- Confirmed the repository does not currently contain decisive per-node route edges (`next`); do not invent a mapdata condition until the actual route source is available. +- Read the captured logs for the interrupted chapter-6 run and the earlier chapter-1 failure. The chapter-6 advance click followed a positive overlay recognition; the real historical failure was the old no-ship-marker fallback to `CHOOSE_FLEET`. + +### Fleet-page fallback requirement +- Record the next safety boundary: validate the fleet-acquisition template before OCR/clicks, re-detect and route when it is absent, and only mark `_has_chosen_fleet` after successful completion. +- No production change or device run performed for this requirement yet. +- Started the first mock insufficient-fleet E2E, then stopped it when the case design was found to enter through `使用上次舰队` instead of isolating the requested same-task retreat/re-entry path. +- Removed the flawed mock flag; no production code was changed by that attempt. + +### Staged entry recognition completion +- Initialized `_skip_advance_choice` in `DecisiveBase` so post-card recognition is valid for every controller instance. +- Fixed the unrecognized-use-last-fleet unit test to advance its mocked monotonic clock past the polling deadline. +- Added the same state field to the delayed-entry test context. +- First rerun failed because that context also lacked `_use_last_fleet_attempts`; added the existing state field to the fixture. +- Second rerun exposed a stale mock setup still targeting `detect_decisive_phase`; configured the new `wait_for_entry_phase` return value instead. +- Review found the handler was checking the pre-poll screenshot after staged recognition; moved the screenshot after the poll and added an assertion for the staged-recognition call. +- Direct `uv run ruff check ...` could not start because `ruff` is not in the locked runtime environment; the repository pre-commit hook supplied ruff and passed after fixing 5 import/format issues. +- Removed the legacy ship-icon gate from the final map-page fallback: positive map-page recognition now routes directly to `PREPARE_COMBAT`, matching the requested formation fallback without assuming fresh entry or resume state. +- Added an offline regression test for the staged order: `USE_LAST_FLEET` -> `ADVANCE_CHOICE` -> confirmed map-page fallback. + +### Final offline verification +- `uv run pytest -q testing/ops`: `102 passed`. +- `uv run python -m compileall -q autowsgr/ops/decisive autowsgr/ui/decisive testing/ops/test_decisive_unit.py tools/e2e`: passed. +- `uv run pre-commit run --files ...`: all selected hooks passed. +- `git diff --check`: passed. +- No real-device run was started after this implementation; the recovery-chain E2E remains pending the user's chapter-6 reset. + +### Real-device case preflight +- Reviewed `tools/e2e/cases/decisive.py` before starting hardware actions. +- Found the recovery-chain case expected `ADVANCE_CHOICE` immediately after map entry and did not handle the valid recognized `USE_LAST_FLEET` step after a chapter reset. +- The case needs an optional positive `USE_LAST_FLEET` route before its `ADVANCE_CHOICE` assertion; production code is unchanged for this tool-only correction. +- Initial tool pre-commit then reported the case's pre-existing complexity plus three nested-if warnings; added the established single-E2E complexity noqa and combined those conditionals. + +### Real-device attempt 2026-09-08 02:31 +- Device `127.0.0.1:16384` connected at 1280x720 and chapter 6 overview navigation passed. +- Case 1 passed `USE_LAST_FLEET` positive recognition/click, confirmation, `ADVANCE_CHOICE` positive recognition, and advance-card/confirm clicks. +- Case 1 then failed while waiting for the post-card state with `决战入口页面未识别到预期弹窗或地图页`. +- The retained `NavError_023142_320.png` and `NavError_023155_202.png` screenshots show the actual screen is the `战备舰队获取` overlay, so the failure is an offline template/threshold or entry-poll recognition issue, not a blind advance click. +- The E2E framework recovered by restarting the game and disconnected the device; no battle was started. +- Offline matching against the retained screenshot gave `decisive_fleet_acq=0.7427`, below the old `0.85` threshold; narrowed the fleet-overlay threshold to `0.70` while preserving `0.85` for other decisive templates. +- Made recovery-chain Case 1 reset chapter 6 explicitly after reaching the overview, so a failed prior attempt cannot contaminate the next fresh-entry case. + +### Real-device attempt 2026-09-08 02:38 +- The second run connected successfully, but the previous failed run had left the game in the fleet-acquisition page; `reset_chapter()` was invoked against that page and timed out without a confirmation dialog. +- User confirmed the device was visibly stuck on the `战备舰队获取` page. No further clicks were issued after this confirmation; the runner's cleanup/restart path was allowed to finish. +- This proves the recovery-chain runner needs an explicit precondition/recovery step that verifies the decisive overview before resetting, rather than assuming a prior failed run left the overview usable. + +### User reset confirmation +- User confirmed chapter 6 and the device state were reset after the contaminated second attempt; safe to retry the recovery-chain. + +### Real-device attempt 2026-09-08 02:44 (user stopped) +- Third run connected and reached the decisive overview, then entered the explicit Case 1 reset action. +- User requested an immediate stop while the runner was in its recovery path; no `python`/`uv` E2E process remains. +- The four-case chain was not reached in this run. No further device actions should be taken until the user gives a new instruction. + +### OCR tool inventory +- The current worktree already contains the compatible OCR tools `tools/ocr_crop_tool.py` and `tools/ocr_change_fleet_e2e/`. +- The same-named files in `20260830-autowsgr-upgrade` use the separate `autowsgr.application.*` architecture and must not be copied into this branch. + +### Fixed last-fleet ROI recognition +- Added the 720p normalized ROI from the user's red-box screenshot: `x=0.82..1.00`, `y=0.30..0.50`. +- The entry stage now waits 3 seconds, performs three immediate ROI template checks, and downgrades to `ADVANCE_CHOICE` after three misses. +- The actual click path uses the same ROI so recognition and clicking share one coordinate boundary. +- First ROI test rerun exposed a stale fixture that did not mock the new helper; added the helper mock before rerunning. +- Second ROI test rerun exposed the same test's stale assertion for the removed generic use-last call; narrowed it to the remaining advance-choice stage. + +### Fixed ROI verification +- `uv run pytest -q testing/ops`: `103 passed`. +- Selected pre-commit hooks, compileall, and `git diff --check` passed. +- No real-device run was started for this ROI change; the user's screenshot supplied the 720p coordinate basis. + +### User authorized ROI real-device run +- User requested rerunning the original four-case recovery-chain with the fixed-ROI implementation. +- User then authorized automatic chapter reset for this run. +- Updated Case 2's mock to click the first real card in the game while bypassing OCR, close the overlay, retain exactly one ship in state, and let the mocked node-A fleet check trigger retreat. + +### Real-device attempt 2026-09-08 03:10 +- Automatic reset did issue the reset-coordinate click, but the screenshot at timeout showed the decisive map with `A1/A2` `ADVANCE_CHOICE`, not the chapter-overview reset confirmation. +- Therefore the click landed on the advance-choice confirm area; `confirm_operation()` was waiting for the wrong dialog. The real defect is a missing overview-page precondition before `reset_chapter()`, not a missing generic confirm template. +- The run stopped before Case 1; no further device action was issued after this evidence. + +### Reset-entry recognition fix +- Added the user-provided `reset_button.png` as a 1280x720 decisive template. +- `reset_chapter()` now waits for a positive reset-button match and clicks its match center before invoking the existing generic confirmation recognition. +- Added an offline regression test proving the reset coordinate is never used when the reset button is not recognized. +- Refined reset recognition to the screenshot ROI `x=0.64..0.73`, `y=0.84..1.00` before matching, then click the matched center. +- Confirmed the existing entry state machine semantics with a regression: `REFRESH` resets, re-detects `REFRESHED`, then enters the map. + +### User authorized reset-ROI real-device run +- User confirmed the game is currently on the home page and chapter 6 is not reset; run the recovery-chain with automatic reset-button ROI recognition. + +### Real-device attempt 2026-09-08 03:38 +- Reset ROI and confirmation passed; Case 1 and Case 2 completed successfully, including a real first-card click in the Case 2 mock and normal retreat handling. +- Case 3 reached normal fleet selection and the formation page, but the E2E case hard-coded unavailable `U-47`; OCR had purchased `鹦鹉螺` and `M-296`. +- Changed Case 3 to use the actual ships recorded in the current run for formation; production code was not changed by this correction. + +### Real-device attempt 2026-09-08 21:51 +- Automatic reset, Case 1, and Case 2 passed again. +- Case 3 passed advance recognition, normal purchase, formation-title guard, and actual formation completion. +- Case 3 still failed only at returning from preparation to the map; Case 4 was not reached. This remains the pending event/page-recognition issue. + +### Real-device attempt 2026-09-08 21:51 repeat +- Automatic reset, Case 1, Case 2, Case 3 formation entry, formation-title guard, and actual formation completion passed again. +- After the preparation-page back click, `BATTLE_PREP -> MAP` recognition timed out; no temporary-leave action or Case 4 action was reached. +- This reproduces the same page-recognition boundary independently of formation entry and fleet OCR. + +### Real-device attempt 2026-09-08 03:41 +- The run stopped before decisive navigation: an event-map stage-card overlay repeatedly blocked `定位决战总览页` and the framework timed out returning to the main page. +- No reset click or decisive case action occurred in this run; no further device recovery clicks were issued. +- User confirmed the actual device is currently on the fleet-formation page despite the framework cleanup summary; preserve this page and do not auto-navigate. +- Latest screenshots and logs show this run did not click formation: the page was already `出征准备`, while the framework misrecognized it as `活动页面 (score=0.870)` and repeatedly clicked the event close coordinate. The formation entry came from the previous Case 3 run, whose failed cleanup falsely reported returning home. + +### Real-device attempt 2026-09-08 21:45 +- Automatic reset ROI and confirmation passed. +- Case 1 and Case 2 passed completely; Case 2 physically clicked the first fleet card before the insufficient-fleet retreat check. +- Case 3 passed advance recognition, normal fleet acquisition, formation entry, and formation completion using the actual purchased `M-296` and `鹦鹉螺`. +- Case 3 failed only while returning from formation to the map, reproducing the known `EVENT_MAP` false-positive page-recognition issue; Case 4 was not reached. + +### Tomorrow handoff +- Production event-page recognition still needs the bottom-right fight-button ROI fix described in `findings.md`. +- Do not resume real-device recovery-chain until that false-positive fix is tested offline. + +### Decisive formation title guard +- Added `fleet_name.png` as a 1280x720 OpenCV template with ROI `x=0.08..0.26`, `y=0.11..0.22`. +- `enter_formation()` now requires three title checks; on failure it returns to the map and retries formation once before raising. +- Verification: focused decisive tests `11 passed`; full `testing/ops` `113 passed`; selected pre-commit and compile checks passed. + +### Formation back-return diagnosis +- The back click succeeds and the screenshot is already the decisive map. +- The wait path targets generic `PageName.MAP`, while decisive-map recognition lives only in `DecisiveMapController.is_decisive_map_page()`; this is the primary return timeout cause. +- Event-page false matching affects the first recognition frame but is secondary to the wrong target checker. + +### Commit checkpoint +- Code/E2E/config checkpoint committed as `5c2eb12` (`fix(decisive): gate entry and reset actions by recognition`). + +### Baseline synchronization +- Rebased the task branch onto `origin/ShiinaKuroko@9b000b4` without conflicts. +- Rebased code checkpoint: `70517d1`; rebased planning checkpoint: `e927739`. +- Post-rebase verification: `uv run pytest -q testing/ops` -> `111 passed`; compileall, selected pre-commit hooks, and diff check passed. + +### Event-page false-positive diagnosis +- `BaseEventPage.is_current_page()` checks the generic `fight_button_20260730_540p.png` first at confidence `0.8`. +- On the decisive formation screenshot, that full-screen match falsely hits the top-left back button at confidence `0.86994`; difficulty-icon and event-title checks were not involved. +- Because `EVENT_MAP` is registered before `DECISIVE_BATTLE`/other page candidates, the false event hit wins page recognition. The minimal fix boundary is an event-fight-button bottom-right ROI (the real event button location), not a reset or decisive-flow change. + +### 2026-09-09: map data cross-check + +- Read the normal-map YAML contract and the supplied decisive forward/enemy YAMLs. +- Built a read-only normalized preview: 18 maps, 319 nodes, 401 edges; all terminal labels match legacy `map_end`. +- First structural probe used the legacy enemy shape as a mapping and failed because `enemy_spec.yaml` stores `enemy` as a padded list; reran with shape-aware parsing. +- One inspection probe referenced a non-existent `autowsgr/types/decisive.py`; the enum is defined in `autowsgr/types.py`. No repository files were changed by either failed read. + +### 2026-09-09: normalized decisive map archive + +- Generated 18 `autowsgr/data/map/decisive_battle/silent_warrior/EX-*.yaml` files from the supplied forward graph and enemy formations, using branch-qualified node IDs and normal-map `next` semantics. +- Removed the temporary combined archive and normalized enemy formations through `ShipType` member names, with `AF` retained for the special `机场` unit. New map data stores only actual enemy codes; it does not copy the legacy index-0 sentinel. +- Corrected the two legacy decisive aliases after validation: `CBG -> BG` for `大巡` and `BG -> BBG` for `导战`. +- The first conversion assertion assumed every source formation had six units; the source contains 1-6 actual units. The new archive preserves those lengths instead of padding them. +- The data test first rejected legacy empty padding, then caught the incompatible `CBG` code; both issues were corrected without weakening unknown-code validation. + +### 2026-09-09: route data runtime integration + +- Replaced `MapData`'s static `map_end`/`key_points` and legacy `enemy_spec.yaml` loader with per-EX `silent_warrior` map loading. +- Added leftmost-route successor queries; no column or route cursor is persisted. +- Added the three-card ROI from `adb-teamchose3.png`; unknown recovery state checks both two-card and three-card ROIs, while known route state selects the matching ROI. +- Removed `get_advance_choice()`'s unconditional index-0 decision; the handler now always clicks the leftmost card after route-derived recognition. +- Deleted `autowsgr/data/map/decisive_battle/enemy_spec.yaml` after removing all Python references. +- Three-card ROI is `x=142..429, y=235..431` at 1280x720, taken from `adb-teamchose3.png`; the existing two-card ROI remains unchanged. +- The first integration pre-commit caught a `TypeError` lint and a missing ROI return annotation; both were fixed and the second run passed. +- The first data test exposed unreachable legacy key points; filtered each map's key points to its actual node labels and recorded the mismatch without changing `enemy_spec.yaml`. +- Data contract test: `uv run pytest -q testing/ops/test_decisive_map_data.py` -> `1 passed`. +- File-scoped pre-commit (including Ruff, YAML/file checks, and codespell) passed. +- The first generation attempt used the wrong legacy-data path and failed before writing; the corrected generation completed with 18 maps, 319 nodes, and 401 edges. + +### Stability run attempt 2026-09-09 08:24 + +- Started the requested 9-ticket run with `--stop-after-tickets` and seed `314859790`. +- The stale `ADVANCE_CHOICE` screen was recovered by the normal startup path: page recognition failed, then the game was force-restarted to the home page. +- Ex-6 navigation and `challenging` entry recognition passed. +- `reset_button.png` was recognized twice at `(0.684, 0.932)`, but neither click opened a confirmation dialog; the run halted before ticket 1. +- Failure screenshot shows the unchanged `挑战中` overview, with no ship-depot dialog visible. Do not repeat the same reset click without a new state explanation. +- The first inline behavior-check command was invalid Python because class declarations cannot follow semicolons; reran the check with `type()` fakes successfully. +- Updated `tools/e2e/cases/decisive_stability.py`: only `refresh` invokes `reset_chapter()`; `challenging` is explicitly resumed without chapter reset. + +### Stability run health check 2026-09-09 09:07 + +- The `uv` runner and child Python processes remained alive (runner PID `14692`); the requested serial `127.0.0.1:16384` remained online. +- The active debug log continued through `09:08:20`, during formation/fight recognition in ticket 1 stage 3. The file metadata timestamp lagged behind the flushed log content, so health was judged from fresh tail lines and process/device state. +- No restart or recovery was needed at this checkpoint. + +### Stability run health check 2026-09-09 09:22 + +- Runner PID `14692` and child Python processes remained alive; `127.0.0.1:16384` remained online. +- Ticket 2 continued through combat/fleet transitions; fresh log tail reached `09:23:17` in `FIGHT_PERIOD`. +- No restart or recovery was needed. The open log's filesystem `LastWriteTime` remains stale, so the tail timestamp is the reliable activity signal. + +### Stability run health check 2026-09-09 09:37 + +- Runner PID `14692` and child Python processes remained alive; `127.0.0.1:16384` remained online. +- Fresh log tail reached `09:37:54` while ticket 2 resumed stage 3 after a system retreat. No process restart or manual intervention was needed. + +### Stability run guard verification 2026-09-09 09:52 + +- Restarted the stability case after adding the consecutive system-retreat guard. The new run is active under a new log directory and reached ticket 1 combat after the preserved `challenging` state. +- Runner and child processes are alive; target ADB serial remains online. No guard trigger or restart has occurred in this verification run yet. + +### Stability run stop 2026-09-09 09:47 + +- Ticket 1 completed all 3 stages and 10 drops with the requested 3 leaves and 1 retreat. +- Ticket 2 completed the requested injections, but then entered an unbounded no-ship loop: `PREPARE_COMBAT -> system retreat -> re-enter -> ADVANCE_CHOICE` repeated hundreds of times without another battle. +- The run was stopped with Ctrl+C after preserving the log; cleanup returned the game to the home page. The last runner summary was 604 steps, 0 failed action steps, but overall FAIL because ticket 2 never cleared. +- Added a five-consecutive-system-retreat guard to the stability case; it marks the report halted and exits after the existing restart/recovery attempt instead of looping until the deadline. + +### Stability run health check 2026-09-09 10:07 + +- Runner PID `15860` and child Python processes remained alive; target ADB serial remained online. +- Ticket 3 produced a `WAITING_FOR_MAP` timeout and recovered by restarting the game; ticket 4 continued afterward with all three leaves completed. +- Fresh log content reached `10:08:33`; no continuous-system-retreat guard trigger yet. + +### Stability run final 2026-09-09 10:24 + +- Final run directory: `logs/e2e_tools/decisive_stability/20260909_095023`. +- Final report: 9 tickets attempted, 1 clear, 8 errors, 2 expedition collections, and 8 automatic restart recoveries. +- Added `debug_report.md` with the ticket-2 leave-confirm failure, tickets-3-9 post-combat `WAITING_FOR_MAP` failures, and the prior no-ship loop evidence. +- No E2E runner remains; the game cleanup path returned to the home page. Compile and `git diff --check` passed after the guard change. + +### Stability run health check 2026-09-09 10:22 + +- Runner PID `15860` and child Python processes remained alive; target ADB serial remained online. +- Ticket 9 is active in combat; the latest log tail reached `10:22:36` in `FIGHT_PERIOD`. +- No guard halt or process restart occurred at this checkpoint. +- The previous progress append failed because its anchor text had changed; no file content was altered by that failed patch. + +### Confirm exit ROI restriction 2026-09-09 + +- Added the fixed `CONFIRM_EXIT_ROI` from the user-marked screenshot: `ROI(363/1280, 161/720, 917/1280, 465/720)`. +- `confirm_exit_720p.png` remains the 526x273 crop template; matching is now restricted to that dialog region instead of the full screen. +- Verification: `uv run pytest -q testing/ops/test_decisive_unit.py` -> `20 passed`; `git diff --check` passed. + +### Entry status ROI restriction 2026-09-09 + +- Added `ENTRY_STATUS_ROI = ROI(547/1280, 635/720, 814/1280, 705/720)` from the user-marked overview screenshot. +- Applied the shared ROI to overview recognition, entry-status polling, and stage-clear return recognition. +- Verification: marked screenshot `entry_challenging_540p` confidence `0.9951`; focused decisive tests -> `22 passed`; `git diff --check` passed. + +### Stage index alignment 2026-09-09 + +- Fixed `recognize_stage()` to return one-based subsection numbers for the per-EX map loader. +- Added unit coverage for stage 1, stage 2, and stage 3/all-complete states. +- Verification: `uv run pytest -q testing/ops/test_decisive_unit.py` -> `23 passed`; `git diff --check` passed. + +### Chapter clear signal 2026-09-09 + +- Separated active stage 3 from completed chapter: all-complete progress markers return `None` and enter `CHAPTER_CLEAR` before map entry. +- Added handler coverage to ensure completed chapters do not click into the map. +- Verification: `uv run pytest -q testing/ops/test_decisive_unit.py` -> `24 passed`; `git diff --check` passed. + +### Node context logging 2026-09-09 + +- Added chapter/stage/node context logging after DLL node recognition. +- Log format: `当前进入为章节 {chapter} 小节 {stage} 的 {node} 列`. +- Verification: focused decisive tests -> `24 passed`; `git diff --check` passed. + +### Temporary leave recovery audit 2026-09-09 + +- Traced production recovery from `_execute_leave()` through the next `run()` invocation and the server task wrapper. +- Confirmed `LEAVE` is a terminal result for the current task invocation; automatic re-entry exists only in the E2E stability harness, which manually reuses the controller. +- Confirmed the no-popup re-entry path is `WAITING_FOR_MAP -> PREPARE_COMBAT`, followed by one ship-marker/DLL node anchor when the node is unknown. +- Recorded the stage-1/node-A exception where `_resume_mode` is cleared before `check_fleet()`, plus the prior stability evidence for stage-3 recovery scanning. + +### Post-combat recognition audit 2026-09-09 + +- Confirmed node localization and DLL recognition already exist as `get_ship_icon_pos*()` plus `recognize_node()`, but the orchestration calls them only for `U` in preparation. +- Confirmed post-combat currently predicts the next letter with `chr(...)` and polls the generic phase detector; it has no dedicated post-combat branch/fleet state resolver. +- Confirmed before the ROI change that `FLEET_ACQUISITION` was a full-screen `fleet_acq_720p.png` template at `0.70`; `ADVANCE_CHOICE` was ROI-limited and route-aware. +- Recorded the fresh-frame inconsistency: the direct fleet-overlay path rechecks a fresh screenshot, while the map-page fallback can return `CHOOSE_FLEET` from a single follow-up overlay match. + +### Fleet overlay context gate 2026-09-10 + +- Added `_fleet_overlay_enabled` without changing `_resume_mode` semantics. +- Kept fleet-overlay recognition enabled for new entry, retreat re-entry, and stage transitions; `_execute_leave()` disables it for same-controller temporary-leave recovery, and non-terminal node results enable it again. +- The first wait still checks ADVANCE_CHOICE before fleet acquisition; fleet matching is only enabled after an advance selection or a post-combat source node. A no-advance/no-source map fallback disables the recovery context. +- Passed the gate through entry and map-phase detection, including the overlay matcher itself; left fleet ROI unchanged until user annotation. +- Verification: `uv run pytest -q testing/ops/test_decisive_unit.py` -> `29 passed`; `uv run pytest -q testing/ops` -> `133 passed`; compileall and `git diff --check` passed. +- Validation note: `uv run ruff check ...` could not start because `ruff` is not installed in the current environment. +- One initial test run failed because two SimpleNamespace fixtures lacked `_advance_source_node`; fixtures were corrected and the rerun passed. + +### Fleet overlay ROI and template 2026-09-10 + +- Detected the marked title ROI as `(494,38)-(799,107)` on the 1280x720 source. +- Cropped the no-red source to `autowsgr/data/images/decisive/fleet_acq_720p.png` (`305x69`), replacing the old bottom-button crop. +- Expanded the runtime match ROI by 1px per side to `(493,37)-(800,108)` while keeping the template crop unchanged. +- Applied the ROI to `detect_decisive_overlay()`, `is_fleet_acquisition()`, and `wait_for_overlay()`. +- OpenCV source match: score `1.0`, location `(494,38)`. +- Project `ImageChecker` validation on the no-red source returned `True` with the new ROI. +- Verification: decisive unit tests `30 passed`; full `testing/ops` `134 passed`; compileall and `git diff --check` passed. + +### Decisive ROI padding audit 2026-09-10 + +- Added `ROI.expand_pixels()` and applied one-pixel padding to all fixed decisive template ROIs. +- Rechecked scaled template dimensions at 1280x720. All fit their padded ROI except the legacy `entry_cant_fight_540p.png` versus the shared entry-status ROI; recorded as a separate asset/ROI mismatch. +- Verification: ROI + decisive tests `44 passed`; full `testing/ops` `134 passed`; compileall and `git diff --check` passed. + +### Fleet acquisition page flow audit 2026-09-10 + +- Traced `CHOOSE_FLEET -> stable screenshot -> OCR score/cost/name -> purchase decision -> card clicks -> close click -> title-disappearance polling -> PREPARE_COMBAT`. +- Changed `_has_chosen_fleet` to commit only after a purchase decision and successful close; empty purchase decisions close, force a current-fleet scan, then defer to `should_retreat()`. +- Added a 1.5-second broad settle wait after title disappearance; removed the empty-selection first-card fallback. +- Verification: decisive tests `33 passed`; full `testing/ops` `137 passed`; ROI + decisive tests `47 passed`. + +### Formation and retreat navigation audit 2026-09-10 + +- Traced map -> formation through `DecisiveMapController.enter_formation()` and its fleet-title verification/retry. +- Traced explicit formation -> map through `DecisiveBattlePreparationPage.go_back()`. +- Traced retreat through `_execute_retreat()` -> `open_retreat_dialog()` -> `go_to_map_page()` -> retreat button -> `CONFIRM_EXIT` -> `confirm_retreat()`. + +### Formation failure handling audit 2026-09-10 + +- Confirmed title recognition has one map-return retry. +- Confirmed click/navigation, fleet change, repair, and sortie failures currently bubble to `DecisiveResult.ERROR`; no generic map-return/retreat cleanup exists. +- Confirmed sortie click itself has no page-arrival verification; combat state is entered after a fixed one-second sleep. + +### Formation error recovery boundary 2026-09-10 + +- Confirmed production `DecisiveController.run()` returns `ERROR` after logging; it does not automatically SL/restart/reset. +- Confirmed server task handling stops after an error result. +- Confirmed restart-to-home plus decisive reset is currently implemented by the stability E2E exception recovery only. + +### Decisive task ERROR retry 2026-09-10 + +- Added 3-attempt task-boundary retry for decisive `ERROR`; each failure invokes SL/restart plus `ensure_game_ready`, including the final exhausted attempt. +- `LEAVE` and success remain terminal without retry. +- Targeted server tests: `4 passed`; full `testing/server/test_task_routes.py` has unrelated temp-directory permission and missing-`set_repairing` fixture failures. + +### SL full recovery check 2026-09-10 + +- Passed `full_recovery_check=True` on retry attempts after ERROR. +- SL recovery keeps ADVANCE/FLEET detection enabled, re-anchors the node, forces current-fleet scanning, and only then permits sortie. +- Verification: decisive unit tests `34 passed`; full `testing/ops` `138 passed`; targeted server retry tests `4 passed`; compileall and `git diff --check` passed. + +### 通用战斗链路审查 2026-09-10 + +- 已从决战出征点击追踪到通用 `CombatEngine` 终止:出征页点击、首轮索敌/阵型/战斗状态识别、战斗过程、夜战、战果采集、结算关闭和决战节点结果轮询。 +- 已确认当前生产边界:出征点击后仅固定等待 1 秒,没有确认真正进入战斗页;战斗状态识别 30 秒超时后只检查终态并可能转成 `SL`;战果关闭在引擎和决战外层各点击一次,分别负责 RESULT→EXP_SETTLEMENT 与关闭经验页,但外层第二次点击后没有到达验证。 +- 新确认:决战外层第二次结算点击没有经过通用识别器;当前只确认了第一次点击到 `EXP_SETTLEMENT`,没有确认第二次点击到 `GET_SHIP` 或决战地图。 + +### 决战战斗与掉落收口边界 2026-09-10 + +- 已用生产调用链确认:决战战斗调用通用 `run_combat()`,没有独立战斗引擎。 +- 已解释历史上“能成功关闭并收集掉落”的原因:决战外层第二次结算点击后,终止节点进入 `confirm_stage_clear()`,该方法自己执行两次确认、掉落 OCR/关闭和入口页确认。 +- 成功实机日志已对齐:`战果成功 -> 小关通关 -> confirm_4 -> confirm_1 -> 10 个掉落 -> 回到决战入口页`。 + +### 普通战结算边界对比 2026-09-10 + +- 已确认普通战/活动战不会在外层重复点击结算;通用引擎按 `MAP_PAGE`/`EVENT_MAP_PAGE` 终态继续处理经验页、掉落页和最终页面。 +- 决战使用 `RESULT` 作为通用引擎终态,外层再补第二次点击并接管终点掉落,因此决战的结算边界与普通战不一致,是当前重构需要优先统一/明确的地方。 +- 用户确认决战上层接管经验结算页点击是正确边界;已移除上一轮临时添加的错误 TODO,未修改运行逻辑。 + +### 战后点击前正向识别保护 2026-09-10 + +- 在共享 `autowsgr/ui/decisive/map_controller.py::enter_formation()` 点击编队前增加决战地图正向识别;未知页面直接拒绝点击。 +- 新增 `test_enter_formation_refuses_unrecognized_page`,并更新既有编队测试夹具。 +- 验证:`uv run pytest -q testing/ops/test_decisive_unit.py` -> `35 passed`;`uv run pytest -q testing/ops` -> `139 passed`;`git diff --check` 通过。 + +### 经验结算识别与成功返回边界 2026-09-10 + +- 已确认并替换经验页判据:固定顶部 ROI OCR 校验 `数字 + Exp`;点击后每 `0.3s` 复检,单次最多 4 帧。 +- 已确认风险:后继页面 4 帧都未识别时 helper 静默返回;由于决战 phase 仍是 `RESULT`,引擎仍可能返回 `OPERATION_SUCCESS`。决战上层第二次点击也未复核经验页是否关闭。 +- 当前仅记录问题,未修改结算行为;后续需要决定失败时返回错误、继续等待还是交由决战上层恢复。 +- 当前仅完成审查和记录,尚未修改通用战斗代码。下一步应先决定“出征到达确认”与“战斗识别超时保真/错误边界”是否作为独立批次,再补对应测试。 + +### 经验结算 ROI OCR 2026-09-10 + +- 按用户干净截图增加固定顶部 ROI OCR:`数字 + Exp` 格式校验,不再使用经验页全屏模板。 +- OCR 每 `0.75s` 循环一次,按 `E/X/P` 增量累积;累积数字和完整 `EXP` 且经过至少 `1.5s` 后才确认经验页,并在确认后等待 1 秒再继续点击。 +- 运行时 `wait_for_phase()` 与 `_click_result_until_closed()` 使用 OCR-aware recognizer;保留静态识别 API 兼容,但静态路径不再匹配 EXP 全屏模板。 +- 增加 OCR 正/负格式与增量 token 测试。验证:经验与结算专项 `27 passed`;`testing/ops` `139 passed`;compileall、`git diff --check` 通过。 +- 实图 OCR 工具验证未完成:Windows 命令行传递中文文件名时路径变乱码,工具在 `imread` 阶段找不到 `经验结算页roi裁切.png`;不是 OCR 判定失败。Fake OCR 格式测试已覆盖正/负结果。 +- 已复制截图到 ASCII 临时路径后完成真实 EasyOCR 验证:1x/2x/4x/8x 均识别出数字与 `Exp`;生产 matcher 实测三帧结果为 `[False, False, True]`。中文原路径乱码只影响工具直接读取,未影响识别结果。 + +### 经验结算一致结果与超时边界 2026-09-10 + +- 成功条件改为三个追加到 list 的完整 `数字+EXP` 结果一致,且总耗时超过 `1.5s`;不是简单三帧计数。 +- `10s` 超时记录“未能识别到经验结算页”并抛出 `TimeoutError`,不返回 `OPERATION_SUCCESS`。 +- 真实生产循环验证结果:`['200EXP', '200EXP', '200EXP']`,约 `1.63s` 确认后等待 1 秒;专项测试 `28 passed`,`testing/ops` `139 passed`。 +- 单个结果 list 固定为 `[数字0, 数字1, 数字2, EXP]`;真实生产循环输出三组 `['2', '0', '0', 'EXP']`,约 `1.96s` 一致后成功。 + +### 经验结算结果 list 语义修正 2026-09-10 + +- 按用户澄清,历史 list 改为只存完整数字:`['200', '200', '200']`;`EXP` 只作为固定格式校验,不占 list 槽位。 +- 真实 EasyOCR 生产循环验证输出 `['200', '200', '200']`,约 `1.75s` 达成一致后等待 1 秒;经验专项 `28 passed`,`testing/ops` `139 passed`。 +## 2026-09-10 State-preserving stability bootstrap + +- Read the active task plan, findings, progress, repository rules, and binding; verified the bound worktree before editing. +- Performed a read-only device diagnostic on `127.0.0.1:16384`; the actual current page was the main page, with no decisive map or overlay match. +- Found two startup assumptions to remove: E2E `prepare()` returns home by default, and the stability case assumes a decisive overview plus `_resume_mode=True`/`ENTER_MAP`. +- Next: add an explicit state-preserving runner mode and a detected-page stability bootstrap; unknown active-map stage must fail closed. +- First patch attempt did not apply because a hunk included comments whose encoding did not match the file; no source changes were made by that attempt. Reapplied using ASCII-only anchors. +- Added `--preserve-state` to the E2E runner and changed decisive stability bootstrap to recognize the current page before navigation. `compileall`, `git diff --check`, argument parsing, and E2E case listing passed. +- Ran the requested six-ticket test until the repeated failure was proven; stopped after ticket 5's same `WAITING_FOR_MAP` failure instead of burning the remaining path. Repeated logs showed retreat re-entry reached `ADVANCE_CHOICE`, then the visible fleet overlay was ignored. +- Re-saved the failure screen with an ASCII tag and measured `FLEET_ACQUISITION` at `0.9999333`; this ruled out template/ROI mismatch and identified `_fleet_overlay_enabled=False` after retreat as the root cause. +- Fixed `_execute_retreat()` to re-enable fleet-overlay recognition and added `test_retreat_reenables_fleet_overlay_for_reentry`. Verification: decisive unit `36 passed`, full `testing/ops` `140 passed`, compileall and diff check passed. +- Enabled `_full_recovery_check=True` in the stability controller bootstrap so an unknown challenging/overlay state checks ADVANCE, fleet, and node evidence before acting. +- A real no-purchase probe showed selecting one lowest-cost card is required before the game accepts the close action. Added lowest-cost fallback selection in `_handle_choose_fleet()` and `test_choose_fleet_uses_low_cost_fallback_card_before_close`. Verification: decisive unit `37 passed`, full `testing/ops` `141 passed`, compileall and diff check passed. +- One fallback patch attempt caused an indentation error in the shared purchase loop; fixed immediately and reran all affected checks successfully. +- Final clean-start stability attempt reset the chapter successfully but still found only one usable last-fleet ship. It was stopped after repeated node-A system retreats; no combat or ticket completion was counted. Partial reports were written under `logs/e2e_tools/decisive_stability/20260910_051504/`. +- Changed `DecisiveLogic.choose_ships()` so every incomplete formation uses ordered `level1` primary candidates followed by `level2` ship backups; added two focused tests. Verification: decisive unit `39 passed`, full `testing/ops` `143 passed`, compileall and diff check passed. + +## 2026-09-12 configured fleet fallback removal + +- Real-device run reached chapter 6 stage 2 node G and exposed the production lowest-cost arbitrary-card fallback: OCR offered `塞瓦斯托波尔` and `格罗兹尼`, neither configured. +- Removed the fallback from `autowsgr/ops/decisive/handlers.py`. +- Replaced the fallback regression with `test_choose_fleet_does_not_buy_unconfigured_card`. +- Verification: `python -m pytest -q testing/ops/test_decisive_unit.py` -> `40 passed`; `git diff --check` passed. +- Updated production flow: when `to_buy == []` and the first close attempt fails, choose the lowest-cost real ship (excluding configured decisive skill cards), close again, and enter `RETREAT`; a successful first close still proceeds to current-fleet sufficiency checks. +- Added `test_choose_fleet_falls_back_to_low_cost_ship_when_empty_close_fails`. +- Verification: decisive unit `41 passed`; full `testing/ops` `145 passed`; `git diff --check` passed. +- Next: rerun one complete real-device decisive round with the current state-preserving launcher. + +## 2026-09-12 decisive fleet priority and repair ordering + +- Reworked `DecisiveLogic.choose_ships()` as a decisive-only algorithm: choose an affordable bundle that maximizes new-ship count, prefers primary ships among equal-size bundles, then adds missing primary ships and upgrades only already-acquired primary ships once the pool has six ships. +- First-node purchase logic now explicitly handles the two-ship affordability cases such as `4+4`, `5+5`, and `6+4`. +- Updated `get_best_fleet()` to retain ships already in the current formation even when the context marks them unavailable, so the decisive preparation flow can repair them before the next sortie. +- Added focused coverage for first-node bundles, six-ship priority, primary-only upgrades, and damaged current ships. Decisive unit tests: `46 passed`; full `testing/ops`: `150 passed`. +- No public smart fleet-change module was modified. +- Next: real-device validation of purchase/formation/repair ordering. + +## 2026-09-12 purchase and formation audit + +- Inspected the current decisive purchase and preparation call chain without changing production code. +- Confirmed the latest node-E log: score `5`, OCR offers did not match configured ships, then `选择购买: []`. +- Confirmed `check_fleet()` opens the formation page and clicks slot 0 before it knows whether the existing formation is sufficient. +- Recorded the next production change boundary: recognize and evaluate the current formation first; open the ship pool only when the target formation is incomplete or a missing configured ship must be found. + +## 2026-09-13 current formation gate + +- Changed `DecisiveMapController.check_fleet()` to inspect the current formation first and skip ship-pool entry when at least one ship is already assigned. +- Kept the original ship-pool scan, sufficiency check, retreat decision, and formation replacement path for an empty formation. +- Changed preparation recovery to merge `all_ships` into the accumulated per-round state rather than overwrite it. +- Added `test_check_fleet_skips_ship_pool_when_current_formation_has_ships`. +- Verification: focused tests `2 passed`; decisive unit tests `47 passed`; full `testing/ops` `151 passed`; `compileall` and `git diff --check` passed. + +## 2026-09-13 full recovery gate and real-device validation + +- Added `scan_ship_pool=True` for `full_recovery_check`, including the first-node path; abnormal restart recovery now scans both current formation and ship pool. +- Kept ordinary non-empty formation scans pool-free and changed fleet scanning to remain on the preparation page so replacement starts immediately. +- The first post-change E2E attempt was blocked by the previous interrupted run leaving the device on the ordinary ship-selection page; the old `initialize` case could not run because it imports the removed `autowsgr.application` package. +- Used the current production `restart_game()` and `ensure_game_ready()` path to restore the device, then ran `decisive --preserve-state --with-ocr --times 1`. +- Final real-device result: `logs/e2e_tools/decisive/20260913_001508`, chapter 6 stages 1-3 completed, result `chapter_clear`, E2E `3 steps, 0 failures`. +- Verification after the final changes: decisive unit tests `48 passed`; full `testing/ops` `152 passed`; compileall and diff check passed. + +## 2026-09-13 subsection node re-anchor + +- Confirmed from the final E2E log that stage 3 did execute A-J, but its entry route incorrectly used `source=A` because stage-clear code carried `node='A'` across the subsection boundary. +- Changed `_handle_stage_clear()` to reset `state.node='U'`; the next subsection now uses map entry source `0` and re-runs live node recognition. +- Added `test_stage_clear_reanchors_next_subsection_from_unknown_node`. +- Verification: targeted node tests `3 passed`; decisive unit tests `49 passed`; full `testing/ops` `153 passed`; compileall and diff check passed. + +## 2026-09-13 stage progress status gate + +- Reworked `recognize_stage()` so the three existing pixel points represent node existence, not completion. +- Added entry-status ROI checks for the ambiguous all-nodes-present case: `ENTRY_REFRESH` means all three subsections are complete; `ENTRY_CHALLENGING` plus `RESET_BUTTON` means subsection 3 is still active; unknown combinations return `0`. +- Captured the actual device overview to `debug/current_decisive_overview.png`; direct production recognition returns `3`, matching the visible third subsection at `0/50`. +- Verification: stage tests `2 passed`; decisive unit tests `50 passed`; full `testing/ops` `154 passed`; compileall and diff check passed. + +## 2026-09-13 remaining stage 3 real-device run + +- Started from the actual challenging overview without resetting the chapter. +- Entry detection returned `第 3 小节正在进行`; preparation logged live node recognition `A` after the `U` anchor. +- Completed stage 3 nodes A through J, collected 10 drops, and reached `chapter_clear`. +- E2E result: `3 steps, 0 failures`; log directory `logs/e2e_tools/decisive/20260913_012346`. + +## 2026-09-13 commit checkpoint + +- Child worktree commit: `d3c1dd0` (`fix(decisive): stabilize fleet and stage recovery`). +- Merged into shared `ShiinaKuroko`: `68bc60d` (`Merge decisive battle stabilization`). +- Shared checkout untracked `.dbg/` was preserved. diff --git a/pyproject.toml b/pyproject.toml index 5a1deaa3..33b0c175 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,9 +178,19 @@ extend-safe-fixes = [ "tools/**" = [ "T201", # print ] +"tools/ocr_change_fleet_e2e/**" = [ + "I001", # import block (黑盒 E2E 框架保持原样) + "INP001", # implicit-namespace-package + "RUF100", # unused-noqa + "TC003", # typing-only-standard-library-import +] "examples/**" = [ "T201", # print ] +"scripts/**" = [ + "T201", # print + "INP001", # implicit-namespace-package (运行时工具脚本, 无需 __init__.py) +] [tool.ruff.lint.mccabe] max-complexity = 17 diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 00000000..eee12d8c --- /dev/null +++ b/task_plan.md @@ -0,0 +1,122 @@ +Task ID: 20260907-autowsgr-decisive-debug-6e3a +Task Status: in_progress +Next Step: Validate subsection re-anchoring and forced full recovery on real device before resuming the six-ticket stability run. + +# Task Plan: Decisive battle debug + +## Goal +Use the isolated AutoWSGR worktree to investigate and fix the decisive-battle module while preserving the shared checkout. + +## Worktree Binding +- Agent ID: 01a07c96-623e-7943-80a3-f73771818301 +- Development branch: `codex/20260907-autowsgr-decisive-debug-6e3a` +- Development worktree: `C:\ShiinaKuroko\01.Project\AutoWSGR\.worktrees\20260907-autowsgr-decisive-debug-6e3a` + +## Next Step +Fleet-overlay gating and the annotated ROI are implemented and tested; proceed to device verification. + +## Current Phase +Phase 4 - Testing & Verification (stability run) + +## Phases + +### Phase 1: Requirements & Discovery +- [x] Understand user intent +- [x] Identify constraints +- [x] Document in findings.md +- **Status:** completed + +### Phase 2: Planning & Structure +- [x] Define staged visual-recognition approach +- [x] Confirm existing templates and state-machine entry points +- **Status:** completed + +### Phase 3: Implementation +- [x] Execute the staged entry-recognition change +- [x] Add focused regression coverage +- [x] Fix decisive preparation return recognition and its stale map template path +- **Status:** completed + +### Phase 4: Testing & Verification +- [x] Verify offline requirements and adjacent operation tests +- [x] Document test results +- [x] Run the four-case real-device recovery-chain after chapter 6 is reset +- [x] Verify decisive preparation return uses decisive-map recognition +- **Status:** completed + +### Phase 5: Delivery +- [x] Review outputs +- [x] Deliver to user +- **Status:** completed + +### Phase 6: Overnight Stability Run +- [x] Recover the refresh-state reset path; `refresh -> reset -> refreshed` passed during ticket 2. +- [x] Run the requested 9-ticket attempt with three leaves and one retreat per ticket; ticket 1 cleared and tickets 2-9 produced recorded errors/recoveries. +- [x] Collect expeditions during leave/recovery windows; 2 collections recorded. +- [x] Perform periodic process, ADB, and log health checks; no runner/device loss was observed. +- [x] Write stability and debug reports. +- **Status:** completed with failures documented + +### Phase 7: Production Follow-up +- [ ] Fix post-combat `WAITING_FOR_MAP` recognition after result-page click. +- [ ] Fix or diagnose `confirm_exit` recognition during injected leave. +- [ ] Rerun stability coverage after the production fixes. +- **Status:** in progress + +### Phase 8: Generic Combat Boundary Audit +- [x] Trace sortie click, combat phase recognition, result collection, and decisive post-combat routing +- [x] Record timeout, recovery, and duplicate-click boundaries +- [ ] Define the smallest production change and regression test before implementation +- **Status:** in progress + +### Phase 9: State-Preserving Stability Bootstrap +- [x] Prevent the E2E runner from forcing the device back to the home page for stability runs. +- [x] Detect the actual initial page and only navigate when the device is outside the decisive flow. +- [x] Refuse unknown in-map stage state instead of guessing a chapter subsection. +- [x] Run a real-device smoke check from the detected current state before starting ticket coverage. +- **Status:** completed with device precondition blocker + +## Decisions Made +| Decision | Rationale | +|----------|-----------| +| Create a new local task branch from `ShiinaKuroko` | Existing debug worktrees belong to unrelated or completed tasks, and the current branch has advanced to `c5a464c`. | +| Place the worktree under `AutoWSGR/.worktrees` | The user explicitly requested the repository-owned path. | +| Preserve the shared checkout | It contains pre-existing user changes and must not be switched, cleaned, or overwritten. | + +## Map Data Follow-up (2026-09-09) + +- [x] Read the normal-map node contract and the supplied decisive route/enemy sources. +- [x] Add one normalized `silent_warrior/EX-*.yaml` file per Silent Warrior map with branch-qualified node IDs, directed `next` edges, reachable key points, and runtime enemy codes. +- [x] Add a data contract test and run the full `testing/ops` suite. +- [x] Integrate runtime loading, leftmost route selection, and branch-count ROI recognition without persisting a route column. +- [x] Remove the obsolete `enemy_spec.yaml` data source. + +## Errors Encountered +| Error | Resolution | +|-------|------------| +| Initial planning patch did not match the generated UTF-8 BOM template | Read the generated files and recreated only the new task-scoped planning files with the required task header. | + +### Phase 10: Configured Fleet Selection Verification +- [x] Remove production fallback that buys arbitrary OCR cards +- [x] Add regression coverage for unconfigured fleet cards +- [x] Run decisive unit tests +- [x] Run full adjacent ops tests +- [x] Run one complete real-device decisive round +- **Status:** in progress + +### Phase 11: Decisive Fleet Priority and Repair Ordering +- [x] Rework battle-preparation purchase priority: fill six ships, then primary ships, then primary upgrades +- [x] Preserve primary/backup ordering and first-node two-ship affordability +- [x] Keep current damaged ships in decisive target formation until repair can run +- [x] Keep public smart fleet-change code unchanged +- [x] Add focused regression coverage +- [x] Run full ops tests and one complete normal real-device validation; forced-recovery physical validation remains pending +- **Status:** in progress + +### Phase 12: Subsection Node Re-anchor +- [x] Confirm stage 3 real-device combat coverage and identify the carried-A entry bug +- [x] Reset stage boundary node context to `U` +- [x] Add regression coverage and run adjacent ops tests +- [x] Validate stage-progress recognition against the current real overview screenshot +- [x] Validate the corrected stage boundary on a new real-device round +- **Status:** in progress diff --git a/testing/combat/test_combat.py b/testing/combat/test_combat.py index e05d153a..9685f080 100644 --- a/testing/combat/test_combat.py +++ b/testing/combat/test_combat.py @@ -11,15 +11,23 @@ from autowsgr.combat.history import ( CombatEvent, CombatHistory, + CombatResult, EventType, FightResult, + grade_condition_met, +) +from autowsgr.combat.node_tracker import ( + MapNodeData, + NodePosition, + NodeTracker, + _resolve_event_map_path, ) -from autowsgr.combat.node_tracker import MapNodeData, _resolve_event_map_path from autowsgr.combat.plan import ( _MODE_SPECS, MODE_TRANSITIONS, CombatMode, CombatPlan, + GradeCondition, NodeDecision, parse_map_value, ) @@ -80,10 +88,43 @@ def test_battle_transitions(self): assert CombatPhase.SPOT_ENEMY_SUCCESS in result assert CombatPhase.FORMATION in result - def test_exercise_transitions(self): + def test_exercise_transitions_default_to_slow(self): + """演习默认完整采集:RESULT 先进入经验结算页,再到结束页。""" exercise = MODE_TRANSITIONS[CombatMode.EXERCISE] result = resolve_successors(exercise, CombatPhase.RESULT, '') - assert CombatPhase.EXERCISE_PAGE in result + assert result == [CombatPhase.EXP_SETTLEMENT] + result = resolve_successors(exercise, CombatPhase.EXP_SETTLEMENT, '') + assert result == [CombatPhase.EXERCISE_PAGE] + + def test_exercise_transitions_slow(self): + """慢速 (collect_result_info=True): 经验页入状态机逐页推进。""" + exercise = build_transitions( + ModeCategory.SINGLE, CombatPhase.EXERCISE_PAGE, collect_result_info=True + ) + assert resolve_successors(exercise, CombatPhase.RESULT, '') == [CombatPhase.EXP_SETTLEMENT] + assert resolve_successors(exercise, CombatPhase.EXP_SETTLEMENT, '') == [ + CombatPhase.EXERCISE_PAGE + ] + + def test_normal_result_enters_exp_by_default(self): + """MAP 类默认完整采集:RESULT 先进入经验结算页。""" + normal = MODE_TRANSITIONS[CombatMode.NORMAL] + result = resolve_successors(normal, CombatPhase.RESULT, '') + assert result == [CombatPhase.EXP_SETTLEMENT] + after_exp = resolve_successors(normal, CombatPhase.EXP_SETTLEMENT, '') + assert CombatPhase.PROCEED in after_exp + assert CombatPhase.MAP_PAGE in after_exp + assert CombatPhase.GET_SHIP in after_exp + + def test_normal_result_only_reaches_exp_when_slow(self): + """MAP 类 (慢速): RESULT 只到经验结算页, 掉落/前进/终态从经验页到达。""" + normal = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE, collect_result_info=True) + result = resolve_successors(normal, CombatPhase.RESULT, '') + assert result == [CombatPhase.EXP_SETTLEMENT] + after_exp = resolve_successors(normal, CombatPhase.EXP_SETTLEMENT, '') + assert CombatPhase.PROCEED in after_exp + assert CombatPhase.MAP_PAGE in after_exp + assert CombatPhase.GET_SHIP in after_exp def test_unknown_phase_raises(self): normal = MODE_TRANSITIONS[CombatMode.NORMAL] @@ -228,6 +269,27 @@ def test_simple(self): assert conditions[0].op == '>=' assert conditions[0].value == 2 + @pytest.mark.parametrize( + ('condition', 'expected'), + [ + ('ap>=1', Condition(field='AP', op='>=', value=1)), + ('ap > = 1', Condition(field='AP', op='>=', value=1)), + ('bb>=2', Condition(field='BB', op='>=', value=2)), + ('cv > = 1', Condition(field='CV', op='>=', value=1)), + ('ss + cl > = 3', Condition(field='SS+CL', op='>=', value=3)), + ('all == 6', Condition(field='ALL', op='==', value=6)), + ('cvl ! = 1', Condition(field='CVL', op='!=', value=1)), + ], + ) + def test_accepts_case_and_spaced_operator_compatibility( + self, + condition: str, + expected: Condition, + ): + assert _parse_legacy_condition(condition) == [ + expected, + ] + def test_compound_and(self): conditions = _parse_legacy_condition('(BB >= 2) and (CV > 0)') assert len(conditions) == 2 @@ -830,6 +892,34 @@ def test_default_map_is_none(self): # ═══════════════════════════════════════════════════════════════════════════════ +class TestNodeTracker: + """节点追踪器在第一帧尚无速度方向时的定位测试。""" + + @staticmethod + def _make_tracker() -> NodeTracker: + map_data = MapNodeData( + { + '0': NodePosition('0', 0.20, 0.60, ['A']), + 'A': NodePosition('A', 0.50, 0.50, []), + } + ) + return NodeTracker(map_data) + + def test_first_position_near_first_node_updates_immediately(self) -> None: + """首个有效画面已接近 A 点时,不应等到下一段移动才更新节点。""" + tracker = self._make_tracker() + object.__setattr__(tracker, '_ship_position', (0.49, 0.50)) + + assert tracker.update_node() == 'A' + + def test_first_position_near_start_keeps_zero(self) -> None: + """首个有效画面仍靠近地图起点时,应继续保留未知起始节点。""" + tracker = self._make_tracker() + object.__setattr__(tracker, '_ship_position', (0.21, 0.60)) + + assert tracker.update_node() == '0' + + class TestResolveEventMapPath: """_resolve_event_map_path 中文命名 glob 测试 (自包含, 不依赖真实数据)。""" @@ -903,3 +993,110 @@ def test_load_real_easy_alpha(self): def test_missing_returns_none(self): assert MapNodeData.load_event('20260730', 'H', 99, 'a') is None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GradeCondition + grade_condition_met (战果条件) +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGradeCondition: + """GradeCondition 构造校验 (由 node_args 的 grade 派生的值对象)。""" + + def test_valid_normalizes_case(self): + cond = GradeCondition(node='f', grade='s') + assert cond.node == 'F' + assert cond.grade == 'S' + + def test_invalid_node_rejected(self): + with pytest.raises(ValueError, match='节点名'): + GradeCondition(node='AB', grade='S') + with pytest.raises(ValueError, match='节点名'): + GradeCondition(node='1', grade='S') + + def test_invalid_grade_rejected(self): + with pytest.raises(ValueError, match='战果要求'): + GradeCondition(node='F', grade='X') + + +def _result_with_grades(*pairs: tuple[str, str]) -> CombatResult: + """构造带 RESULT 事件的 CombatResult (pairs: (node, grade))。""" + result = CombatResult() + for node, grade in pairs: + result.history.add( + CombatEvent(event_type=EventType.RESULT, node=node, result=grade), + ) + return result + + +class TestGradeConditionMet: + """grade_condition_met 谓词: 指定节点最后一次结算 >= 条件等级。""" + + cond = GradeCondition(node='F', grade='S') + + def test_met_when_grade_reached(self): + assert grade_condition_met(self.cond, _result_with_grades(('F', 'S'))) is True + assert grade_condition_met(self.cond, _result_with_grades(('F', 'SS'))) is True + + def test_not_met_when_grade_below(self): + assert grade_condition_met(self.cond, _result_with_grades(('F', 'A'))) is False + + def test_not_met_when_node_absent(self): + assert grade_condition_met(self.cond, _result_with_grades(('A', 'SS'))) is False + + def test_not_met_when_no_fight_results(self): + assert grade_condition_met(self.cond, CombatResult()) is False + + def test_multiple_visits_use_last(self): + """同一节点多次结算 (多次经过) 取最后一次。""" + result = _result_with_grades(('F', 'S'), ('F', 'A')) + assert grade_condition_met(self.cond, result) is False + + +class TestCombatPlanConditions: + """node_args 的 grade → CombatPlan.conditions 派生慢/快速路径。""" + + def test_node_args_grade_derives_conditions(self): + plan = CombatPlan.from_dict({'node_args': {'F': {'grade': 'S'}}}) + assert plan.conditions == (GradeCondition('F', 'S'),) + assert plan.collect_result_info is True # 派生慢速采集 + + def test_multiple_grades_collected(self): + plan = CombatPlan.from_dict( + {'node_args': {'F': {'grade': 'S'}, 'G': {'grade': 'A'}}}, + ) + assert plan.conditions == (GradeCondition('F', 'S'), GradeCondition('G', 'A')) + + def test_node_defaults_grade_spreads_to_selected(self): + """grade 放 node_defaults → selected_nodes 的节点全部继承要求。""" + plan = CombatPlan.from_dict( + {'node_defaults': {'grade': 'S'}, 'selected_nodes': ['A', 'F']}, + ) + assert plan.conditions == (GradeCondition('A', 'S'), GradeCondition('F', 'S')) + + def test_no_grade_defaults_to_slow_path(self): + plan = CombatPlan.from_dict({'node_args': {'F': {'night': True}}}) + assert plan.conditions == () + assert plan.collect_result_info is True + + def test_invalid_grade_rejected_at_node_decision(self): + with pytest.raises(ValueError, match='node grade'): + CombatPlan.from_dict({'node_args': {'F': {'grade': 'X'}}}) + + def test_setter_can_disable_default_slow_without_conditions(self): + """兼容 setter: 运行时仍可显式请求快速穿行。""" + plan = CombatPlan.from_dict({}) + assert plan.conditions == () + assert plan.collect_result_info is True + plan.collect_result_info = False + assert plan.collect_result_info is False + + def test_default_plan_transitions_are_slow(self): + """无 grade 条件的计划默认进入经验结算页。""" + plain = CombatPlan.from_dict({}) + plain_successors = resolve_successors(plain.transitions, CombatPhase.RESULT, '') + assert plain_successors == [CombatPhase.EXP_SETTLEMENT] + + fast = build_transitions(ModeCategory.MAP, CombatPhase.MAP_PAGE, collect_result_info=False) + fast_successors = resolve_successors(fast, CombatPhase.RESULT, '') + assert CombatPhase.EXP_SETTLEMENT not in fast_successors diff --git a/testing/combat/test_exp_settlement_recognition_unit.py b/testing/combat/test_exp_settlement_recognition_unit.py new file mode 100644 index 00000000..a7da20ab --- /dev/null +++ b/testing/combat/test_exp_settlement_recognition_unit.py @@ -0,0 +1,133 @@ +"""EXP_SETTLEMENT (经验结算子页) 识别的无设备单元测试。 + +背景 (实机 2026-08-15): 战果页点击后游戏进入经验结算子页, 旧识别器在该页 +返回 None → 引擎等待后继状态超时。 + +当前判据: 固定顶部 ROI OCR 必须识别出 ``数字 + Exp``。 +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from autowsgr.combat import handlers as handlers_module +from autowsgr.combat.handlers import PhaseHandlersMixin +from autowsgr.combat.recognizer import CombatRecognizer +from autowsgr.combat.state import CombatPhase +from autowsgr.image_resources import TemplateKey +from autowsgr.vision import OCRResult + + +class TestExpSettlementByOcr: + def test_default_signature_unchanged(self): + """exclude_template_key 默认 None — 其他状态不受否决逻辑影响。""" + from autowsgr.combat.recognizer import PhaseSignature + + sig = PhaseSignature(template_key=TemplateKey.PROCEED) + assert sig.exclude_template_key is None + + def test_exp_settlement_signature_uses_runtime_ocr(self): + """EXP_SETTLEMENT 没有全屏模板,运行时走 ROI OCR。""" + sig = CombatRecognizer.get_signature(CombatPhase.EXP_SETTLEMENT) + assert sig.template_key is None + assert sig.exclude_template_key is None + assert sig.confidence == 0.85 + assert sig.after_match_delay == 1.0 + + def test_static_identify_does_not_use_exp_template(self): + frame = np.zeros((540, 960, 3), dtype=np.uint8) + assert CombatRecognizer.identify_current(frame, [CombatPhase.EXP_SETTLEMENT]) is None + + def test_result_signature_uses_grades(self): + """RESULT 签名用评级字母 (仅战果页出现), 不再用 "点击继续" 文字 + (两页都有且被舰船立绘遮挡致分数波动)。""" + sig = CombatRecognizer.get_signature(CombatPhase.RESULT) + assert sig.template_key == TemplateKey.RESULT_GRADES + assert len(TemplateKey.RESULT_GRADES.templates) == 6 + + def test_runtime_exp_ocr_accepts_digits_and_exp(self): + class FakeOCR: + def recognize(self, _image, allowlist=''): + assert '0123456789' in allowlist + return [OCRResult(text='200', confidence=0.99, bbox=(0, 0, 20, 20)), OCRResult( + text='Exp', confidence=0.99, bbox=(25, 0, 50, 20) + )] + + recognizer = CombatRecognizer(SimpleNamespace(ctrl=None, ocr=FakeOCR())) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + + assert recognizer.identify_current_runtime(frame, [CombatPhase.EXP_SETTLEMENT]) == ( + CombatPhase.EXP_SETTLEMENT + ) + + def test_incremental_exp_tokens_accumulate_until_stable(self, monkeypatch): + class FakeOCR: + def __init__(self): + self.calls = 0 + + def recognize(self, _image, allowlist=''): + self.calls += 1 + text = ('200E', 'X', 'P', '200E', 'X', 'P', '200E', 'X', 'P')[ + self.calls - 1 + ] + return [OCRResult(text=text, confidence=0.99, bbox=(0, 0, 20, 20))] + + fake_ocr = FakeOCR() + recognizer = CombatRecognizer(SimpleNamespace(ctrl=None, ocr=fake_ocr)) + host = SimpleNamespace(_device=MagicMock(), _recognizer=recognizer) + host._device.screenshot.return_value = np.zeros((720, 1280, 3), dtype=np.uint8) + now = [0.0] + monkeypatch.setattr(handlers_module.time, 'monotonic', lambda: now[0]) + monkeypatch.setattr( + handlers_module.time, + 'sleep', + lambda delay: now.__setitem__(0, now[0] + delay), + ) + + PhaseHandlersMixin._wait_for_exp_settlement(host) + + assert fake_ocr.calls == 9 + assert now[0] == 7.0 + + def test_runtime_exp_ocr_rejects_missing_exp_suffix(self): + class FakeOCR: + def recognize(self, _image, allowlist=''): + return [OCRResult(text='200', confidence=0.99, bbox=(0, 0, 20, 20))] + + recognizer = CombatRecognizer(SimpleNamespace(ctrl=None, ocr=FakeOCR())) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + + assert recognizer.identify_current_runtime(frame, [CombatPhase.EXP_SETTLEMENT]) is None + + def test_runtime_exp_ocr_rejects_other_characters(self): + class FakeOCR: + def recognize(self, _image, allowlist=''): + return [OCRResult(text='200经验', confidence=0.99, bbox=(0, 0, 40, 20))] + + recognizer = CombatRecognizer(SimpleNamespace(ctrl=None, ocr=FakeOCR())) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + + assert recognizer.recognize_exp_settlement_text(frame) is None + + def test_incremental_exp_timeout_raises_error(self, monkeypatch): + class FakeOCR: + def recognize(self, _image, allowlist=''): + return [OCRResult(text='200E', confidence=0.99, bbox=(0, 0, 40, 20))] + + recognizer = CombatRecognizer(SimpleNamespace(ctrl=None, ocr=FakeOCR())) + host = SimpleNamespace(_device=MagicMock(), _recognizer=recognizer) + host._device.screenshot.return_value = np.zeros((720, 1280, 3), dtype=np.uint8) + now = [0.0] + monkeypatch.setattr(handlers_module.time, 'monotonic', lambda: now[0]) + monkeypatch.setattr( + handlers_module.time, + 'sleep', + lambda delay: now.__setitem__(0, now[0] + delay), + ) + + with pytest.raises(TimeoutError, match='未能识别到经验结算页'): + PhaseHandlersMixin._wait_for_exp_settlement(host) diff --git a/testing/combat/test_result_dismiss_unit.py b/testing/combat/test_result_dismiss_unit.py new file mode 100644 index 00000000..77429b7f --- /dev/null +++ b/testing/combat/test_result_dismiss_unit.py @@ -0,0 +1,291 @@ +"""战果类页面验证式关闭 (_click_result_until_closed) 的无设备单元测试。 + +背景 (实机 2026-08-15 日志, 两轮迭代): + 1. 结算页连点可能被模拟器吞掉, 引擎在页面未退出时即返回 → NavError。 + 2. 修复一版用"原页面签名消失"当成功判据, 但点击 RESULT 后游戏先进入 + **经验结算子页** (无对应 CombatPhase 状态): 复检在该页误判成功提前 + 返回, 引擎等待 PROCEED/GET_SHIP 等状态 7.5s 全落空 → 恢复失败 → + 强制重启游戏。 + 现行判据是**到达验证**: 在 ``[phase] + 后继状态`` 集合上识别, + 命中后继才算成功, 识别不到任何状态 (未知中间页) 则交外层状态机确认。 +经验结算子页后正式注册为 ``CombatPhase.EXP_SETTLEMENT``, 不再是盲点。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +import numpy as np +import pytest + +from autowsgr.combat import handlers as handlers_mod +from autowsgr.combat.handlers import PhaseHandlersMixin +from autowsgr.combat.history import CombatEvent, CombatHistory, EventType +from autowsgr.combat.state import CombatPhase + + +class _Host(PhaseHandlersMixin): + """最小宿主: 只提供 _click_result_until_closed 用到的属性。""" + + def __init__( + self, + device: MagicMock, + recognizer: MagicMock, + end_phase: CombatPhase | None, + collect_result_info: bool = True, + ) -> None: + self._device = device + self._recognizer = recognizer + self._ocr = None + plan = MagicMock() + plan.end_phase = end_phase + plan.collect_result_info = collect_result_info + self._plan = plan + # _handle_result 用到的其余属性 + self._ship_stats: list = [None] * 6 + self._history = MagicMock() + self._node = 'A' + + +def _make_host( + phase_results: list[CombatPhase | None], + end_phase: CombatPhase | None = None, + collect_result_info: bool = True, +) -> tuple[_Host, MagicMock, MagicMock]: + """构造宿主; *phase_results* 为每次点击后复检的返回序列 (None=中间页)。""" + device = MagicMock() + device.screenshot.return_value = np.zeros((540, 960, 3), dtype=np.uint8) + recognizer = MagicMock() + recognizer.identify_current.side_effect = phase_results + return _Host(device, recognizer, end_phase, collect_result_info), device, recognizer + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr('autowsgr.combat.handlers.time.sleep', lambda *_: None) + + +class TestClickResultUntilClosed: + def test_reaches_successor(self): + """点击一次即识别到后继 (PROCEED) → 成功, 只点一次。""" + host, device, recognizer = _make_host([CombatPhase.PROCEED]) + host._click_result_until_closed(CombatPhase.RESULT) + assert device.click.call_count == 1 + recognizer.identify_current.assert_called_once() + + def test_intermediate_page_waits_for_confirmation(self): + """过渡帧未确认时只等待, 不盲点跳过尚未识别的掉落页。""" + host, device, recognizer = _make_host( + [None, None, None, None, CombatPhase.GET_SHIP] + ) + host._click_result_until_closed(CombatPhase.RESULT) + assert device.click.call_count == 1 + assert recognizer.identify_current.call_count == 4 + + def test_retries_while_signature_remains(self): + """前两次点击被吞 (签名仍在) → 第三次到后继, 共点 3 次。""" + host, device, _ = _make_host([CombatPhase.RESULT, CombatPhase.RESULT, CombatPhase.PROCEED]) + host._click_result_until_closed(CombatPhase.RESULT) + assert device.click.call_count == 3 + + def test_pass_through_page_keeps_clicking(self): + """命中 pass_through 过渡页 (快速穿行的经验页) → 继续点击直到真正后继。""" + host, device, _ = _make_host([CombatPhase.EXP_SETTLEMENT, CombatPhase.GET_SHIP]) + host._click_result_until_closed( + CombatPhase.RESULT, pass_through=(CombatPhase.EXP_SETTLEMENT,) + ) + assert device.click.call_count == 2 + + def test_gives_up_after_attempts(self): + """持续停在原页面 → 达到 attempts 上限后停止, 不抛异常 (交上层处理)。""" + host, device, _ = _make_host([CombatPhase.RESULT] * 10) + host._click_result_until_closed(CombatPhase.RESULT, attempts=4) + assert device.click.call_count == 4 + + def test_clicks_result_coordinate(self): + """点击坐标走 Coords.CLICK_RESULT (与 combat/actions.click_result 一致)。""" + from autowsgr.combat.actions import Coords + + host, device, _ = _make_host([CombatPhase.PROCEED]) + host._click_result_until_closed(CombatPhase.GET_SHIP) + assert device.click.call_args == call(*Coords.CLICK_RESULT) + + +class TestResultSuccessors: + def test_event_result_includes_end_phase(self): + """活动战斗 (慢速): RESULT 后继含终态页 + 经验页 (逐页推进)。""" + host, _, _ = _make_host([], end_phase=CombatPhase.EVENT_MAP_PAGE) + assert set(host._result_successors(CombatPhase.RESULT)) == { + CombatPhase.PROCEED, + CombatPhase.FLAGSHIP_SEVERE_DAMAGE, + CombatPhase.EVENT_MAP_PAGE, + CombatPhase.GET_SHIP, + CombatPhase.EXP_SETTLEMENT, + } + + def test_fast_result_excludes_exp(self): + """快速穿行: 经验页是 pass_through 过渡页, 不在 RESULT 到达集合。""" + host, _, _ = _make_host([], end_phase=CombatPhase.MAP_PAGE, collect_result_info=False) + assert set(host._result_successors(CombatPhase.RESULT)) == { + CombatPhase.PROCEED, + CombatPhase.FLAGSHIP_SEVERE_DAMAGE, + CombatPhase.MAP_PAGE, + CombatPhase.GET_SHIP, + } + + def test_campaign_result_no_end_phase(self): + """战役 (end_phase=None): RESULT 后继不含终态页, 与转移图一致。""" + host, _, _ = _make_host([], end_phase=None) + assert set(host._result_successors(CombatPhase.RESULT)) == { + CombatPhase.PROCEED, + CombatPhase.FLAGSHIP_SEVERE_DAMAGE, + CombatPhase.GET_SHIP, + CombatPhase.EXP_SETTLEMENT, + } + + def test_exp_settlement_excludes_self(self): + """EXP_SETTLEMENT 后继不含自身, 仍含 GET_SHIP (掉落在经验页之后)。""" + host, _, _ = _make_host([], end_phase=CombatPhase.MAP_PAGE) + successors = host._result_successors(CombatPhase.EXP_SETTLEMENT) + assert CombatPhase.EXP_SETTLEMENT not in successors + assert CombatPhase.GET_SHIP in successors + assert CombatPhase.MAP_PAGE in successors + + def test_get_ship_excludes_self_and_exp(self): + """GET_SHIP 后继不含 GET_SHIP 自身与 EXP_SETTLEMENT (经验页只跟在 RESULT 后)。""" + host, _, _ = _make_host([], end_phase=CombatPhase.MAP_PAGE) + successors = host._result_successors(CombatPhase.GET_SHIP) + assert CombatPhase.GET_SHIP not in successors + assert CombatPhase.EXP_SETTLEMENT not in successors + assert CombatPhase.MAP_PAGE in successors + + +class TestHandleResultModes: + """_handle_result 快速穿行 / 慢速采集双模式。""" + + @pytest.fixture(autouse=True) + def _stub_detectors(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(handlers_mod, 'detect_ship_stats', lambda *_a: _a[1]) + monkeypatch.setattr(handlers_mod, 'detect_result_grade', lambda *_a, **_k: 'S') + monkeypatch.setattr(handlers_mod, 'detect_mvp', lambda *_a, **_k: '鲃鱼') + + def test_fast_passes_through_exp(self): + """快速: 仍采集评级/MVP (始终采集), 但经验页穿行 (pass_through)。""" + host, device, _ = _make_host( + [CombatPhase.EXP_SETTLEMENT, CombatPhase.PROCEED], + collect_result_info=False, + ) + host._handle_result() + # 经验页命中后继续点击, 直到 PROCEED 才停 → 2 次点击 + assert device.click.call_count == 2 + # 始终采集: 快速模式也记录战果事件 (计数器/触发器依赖) + host._history.add.assert_called_once() + + def test_slow_collects_grade_mvp_and_records(self): + """慢速: 采集评级/MVP 记入战斗历史, 经验页是到达点 (只点一次)。""" + host, device, _ = _make_host([CombatPhase.EXP_SETTLEMENT], collect_result_info=True) + host._handle_result() + assert device.click.call_count == 1 + host._history.add.assert_called_once() + event = host._history.add.call_args.args[0] + assert event.event_type.name == 'RESULT' + assert event.result == 'S' + assert event.extra == {'mvp': '鲃鱼'} + + +class TestDropCapture: + """掉落捕获: 幂等 + 点击穿行时兜底捕获。""" + + @pytest.fixture(autouse=True) + def _stub_ocr(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._ocr_calls: list = [] + monkeypatch.setattr( + handlers_mod, + 'get_ship_drop', + lambda *_a, **_k: self._ocr_calls.append(1) or 'SKR6', + ) + + def _make_host_with_history(self, *args, **kwargs): + host, device, recognizer = _make_host(*args, **kwargs) + host._history = CombatHistory() + return host, device + + def test_capture_is_idempotent(self): + """同节点已记录掉落 → 复用历史结果, 不重复 OCR。""" + host, _ = self._make_host_with_history([CombatPhase.PROCEED]) + host._history.add( + CombatEvent(event_type=EventType.GET_SHIP, node='A', result='SKR6'), + ) + result = host._capture_get_ship() + assert result == 'SKR6' + assert self._ocr_calls == [] + + def test_capture_records_new_drop(self): + """首次捕获: OCR 识别 + 记录历史 + 返回舰名。""" + host, _ = self._make_host_with_history([CombatPhase.PROCEED]) + result = host._capture_get_ship() + assert result == 'SKR6' + assert self._ocr_calls == [1] + event = host._history.get_event(EventType.GET_SHIP, 'A') + assert event is not None and event.result == 'SKR6' + + def test_result_dismiss_captures_on_get_ship(self): + """点击穿行命中 GET_SHIP → 立即幂等捕获掉落, 主循环不再重复 OCR。""" + host, device = self._make_host_with_history( + [CombatPhase.GET_SHIP, CombatPhase.PROCEED], + collect_result_info=False, + ) + host._click_result_until_closed(CombatPhase.RESULT) + assert device.click.call_count == 1 + event = host._history.get_event(EventType.GET_SHIP, 'A') + assert event is not None and event.result == 'SKR6' + # 主循环重新派发 _handle_get_ship 时走幂等分支, 不重复 OCR + host._handle_get_ship() + assert self._ocr_calls == [1] + + def test_result_dismiss_retries_unrecognized_get_ship( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """快速捕获失败后, GET_SHIP 处理器应在稳定页面重试 OCR。""" + results = iter([None, 'SKR6']) + monkeypatch.setattr( + handlers_mod, + 'get_ship_drop', + lambda *_a, **_k: self._ocr_calls.append(1) or next(results), + ) + host, _ = self._make_host_with_history( + [CombatPhase.GET_SHIP, CombatPhase.PROCEED], + collect_result_info=False, + ) + + host._click_result_until_closed(CombatPhase.RESULT) + assert host._history.get_event(EventType.GET_SHIP, 'A') is None + + host._handle_get_ship() + + assert self._ocr_calls == [1, 1] + event = host._history.get_event(EventType.GET_SHIP, 'A') + assert event is not None + assert event.result == 'SKR6' + + def test_get_ship_clicks_after_ocr_exhaustion(self, monkeypatch: pytest.MonkeyPatch): + """掉落/上限提示页 OCR 无结果后仍应点击离开。""" + monkeypatch.setattr(handlers_mod, 'get_ship_drop', lambda *_a, **_k: None) + host, device = self._make_host_with_history([CombatPhase.PROCEED]) + + host._handle_get_ship() + + assert device.click.call_count == 1 + + def test_pixel_fallback_captures_on_transition(self): + """模板未命中 (None 过渡帧) 但掉落页像素签名命中 → 兜底捕获。""" + host, device = self._make_host_with_history( + [None], + collect_result_info=False, + ) + host._is_get_ship_page = lambda _screen: True + host._click_result_until_closed(CombatPhase.RESULT) + assert device.click.call_count == 1 + event = host._history.get_event(EventType.GET_SHIP, 'A') + assert event is not None and event.result == 'SKR6' diff --git a/testing/combat/test_ship_drop_recognition_unit.py b/testing/combat/test_ship_drop_recognition_unit.py new file mode 100644 index 00000000..68be337f --- /dev/null +++ b/testing/combat/test_ship_drop_recognition_unit.py @@ -0,0 +1,54 @@ +"""掉落页 OCR 有限重试的无设备单元测试。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from autowsgr.combat import actions as actions_mod +from autowsgr.combat.recognition import ShipDropResult + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: + sleeps: list[float] = [] + monkeypatch.setattr(actions_mod.time, 'sleep', sleeps.append) + return sleeps + + +def test_get_ship_drop_retries_five_frames_before_giving_up( + monkeypatch: pytest.MonkeyPatch, + _no_sleep: list[float], +) -> None: + device = MagicMock() + device.screenshot.return_value = np.zeros((540, 960, 3), dtype=np.uint8) + results = [ShipDropResult(ship_name=None, ship_type=None)] * 4 + results.append(ShipDropResult(ship_name='SKR6', ship_type='驱逐舰')) + monkeypatch.setattr(actions_mod, 'recognize_ship_drop', lambda *_a: results.pop(0)) + + result = actions_mod.get_ship_drop(device, object()) # type: ignore[arg-type] + + assert result == 'SKR6' + assert device.screenshot.call_count == 5 + assert _no_sleep == [0.5, 0.5, 0.5, 0.5] + + +def test_get_ship_drop_returns_none_after_five_misses( + monkeypatch: pytest.MonkeyPatch, + _no_sleep: list[float], +) -> None: + device = MagicMock() + device.screenshot.return_value = np.zeros((540, 960, 3), dtype=np.uint8) + monkeypatch.setattr( + actions_mod, + 'recognize_ship_drop', + lambda *_a: ShipDropResult(ship_name=None, ship_type=None), + ) + + result = actions_mod.get_ship_drop(device, object()) # type: ignore[arg-type] + + assert result is None + assert device.screenshot.call_count == 5 + assert _no_sleep == [0.5, 0.5, 0.5, 0.5] diff --git a/testing/infra/test_config.py b/testing/infra/test_config.py index 175450ee..24017a59 100644 --- a/testing/infra/test_config.py +++ b/testing/infra/test_config.py @@ -128,6 +128,22 @@ def test_unused_bathroom_feature_count_is_removed(self): assert not hasattr(config, 'bathroom_feature_count') + def test_dock_full_destroy_defaults_off_and_allows_opt_in(self): + emulator = EmulatorConfig( + serial='emulator-5554', + path='/tmp/emulator', + ) + default_config = UserConfig(emulator=emulator, os_type=OSType.linux) + opt_in_config = UserConfig( + emulator=emulator, + os_type=OSType.linux, + dock_full_destroy=True, + ) + + assert UserConfig.model_fields['dock_full_destroy'].default is False + assert default_config.dock_full_destroy is False + assert opt_in_config.dock_full_destroy is True + def test_from_yaml(self, tmp_yaml: Callable[[str, str], Path]): content = """\ emulator: @@ -197,10 +213,34 @@ def test_destroy_ship_config(self, tmp_yaml: Callable[[str, str], Path]): assert cfg.destroy_ship_work_mode == DestroyShipWorkMode.include assert len(cfg.destroy_ship_types) == 2 + @pytest.mark.parametrize( + ('field', 'value'), + [ + ('operation_delay_min', -0.1), + ('operation_delay_max', -0.1), + ('operation_delay_min', float('nan')), + ('operation_delay_max', float('inf')), + ], + ) + def test_operation_delay_rejects_negative_or_nonfinite_values( + self, + field: str, + value: float, + ) -> None: + from pydantic import ValidationError -# ── FightConfig ── + with pytest.raises(ValidationError): + UserConfig( + emulator=EmulatorConfig( + serial='emulator-5554', + path='/fake/dnplayer.exe', + ), + **{field: value}, + ) +# ── FightConfig ── + class TestFightConfig: def test_repair_mode_expanded(self): cfg = FightConfig(repair_mode=RepairMode.moderate_damage) @@ -459,6 +499,7 @@ def test_detect_clean_fleet_empty(self): from autowsgr.infra.config_compat import detect_legacy_plan assert detect_legacy_plan({'fleet': ['吹雪', '明斯克']}) == [] + assert detect_legacy_plan({'fleet': []}) == [] assert detect_legacy_plan({}) == [] def test_detect_leading_empty_fleet(self): diff --git a/testing/ops/test_bath_navigation.py b/testing/ops/test_bath_navigation.py new file mode 100644 index 00000000..a3e865b2 --- /dev/null +++ b/testing/ops/test_bath_navigation.py @@ -0,0 +1,117 @@ +"""测试普通出征与决战进入澡堂的显式导航路线。""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import autowsgr.ops.repair as repair_module +from autowsgr.context import GameContext +from autowsgr.infra import ActionFailedError +from autowsgr.ops import navigate +from autowsgr.types import PageName +from autowsgr.ui.battle.base import RepairStrategy +from autowsgr.ui.battle.preparation import BattlePreparationPage +from autowsgr.ui.utils import NavigationError + + +def test_normal_sortie_returns_to_map_before_bath(monkeypatch: pytest.MonkeyPatch) -> None: + page = MagicMock() + monkeypatch.setattr('autowsgr.ui.battle.preparation.BattlePreparationPage', lambda _ctx: page) + goto_page = MagicMock() + monkeypatch.setattr(navigate, 'goto_page', goto_page) + ctx = object() + + navigate.goto_bath_from_normal_sortie(ctx) + + page.go_back.assert_called_once_with() + goto_page.assert_called_once_with(ctx, PageName.BATH) + + +def test_decisive_sortie_leaves_saved_map_before_bath(monkeypatch: pytest.MonkeyPatch) -> None: + controller = MagicMock() + decisive_config = object() + ctx = SimpleNamespace( + config=SimpleNamespace(decisive_battle=decisive_config), + ctrl=object(), + ) + monkeypatch.setattr( + 'autowsgr.ui.decisive.DecisiveMapController', + lambda _ctx, _config: controller, + ) + goto_page = MagicMock() + monkeypatch.setattr(navigate, 'goto_page', goto_page) + wait_for_page = MagicMock() + monkeypatch.setattr('autowsgr.ui.utils.wait_for_page', wait_for_page) + + navigate.goto_bath_from_decisive_sortie(ctx) + + controller.go_to_map_page.assert_called_once_with() + controller.open_retreat_dialog.assert_called_once_with() + controller.confirm_leave.assert_called_once_with() + wait_for_page.assert_called_once() + assert wait_for_page.call_args.args[0] is ctx.ctrl + assert wait_for_page.call_args.kwargs['target'] is PageName.DECISIVE_BATTLE + goto_page.assert_called_once_with(ctx, PageName.BATH) + + +def test_manual_repair_action_runs_before_manual_repair_error() -> None: + ctx = GameContext( + ctrl=MagicMock(), + config=SimpleNamespace(repair_manually=True), + ocr=None, + ) + page = BattlePreparationPage(ctx) + page.check_repair = MagicMock(return_value=[0]) + manual_repair_action = MagicMock() + + with pytest.raises(ActionFailedError, match='需要进行手动修理'): + page.apply_repair( + RepairStrategy.MODERATE, + manual_repair_action=manual_repair_action, + ) + + manual_repair_action.assert_called_once_with([0]) + + +def test_manual_bath_repair_records_success_and_stops_on_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = MagicMock() + page.repair_ship.side_effect = [120, -1] + ship = MagicMock() + ctx = SimpleNamespace( + bathroom=MagicMock(), + config=SimpleNamespace(bathroom_count=2), + get_ship=lambda _name: ship, + update_ship_damage=MagicMock(), + ) + goto_page = MagicMock() + monkeypatch.setattr(repair_module, 'BathPage', lambda _ctx: page) + monkeypatch.setattr(repair_module, 'goto_page', goto_page) + + repair_module.repair_manual_targets_in_bath(ctx, ['舰一', '舰二']) + + assert page.go_to_choose_repair.call_count == 2 + assert page.repair_ship.call_args_list[0].args == ('舰一',) + ship.set_repair.assert_called_once_with(120) + ctx.update_ship_damage.assert_called_once_with('舰一', 0) + ctx.bathroom.occupy.assert_called_once_with(120) + goto_page.assert_called_once_with(ctx, PageName.MAIN) + + +def test_manual_bath_repair_logs_missing_target_and_returns_home( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = MagicMock() + page.repair_ship.side_effect = NavigationError('target not listed') + ctx = SimpleNamespace() + goto_page = MagicMock() + monkeypatch.setattr(repair_module, 'BathPage', lambda _ctx: page) + monkeypatch.setattr(repair_module, 'goto_page', goto_page) + + repair_module.repair_manual_targets_in_bath(ctx, ['舰一']) + + page.go_to_choose_repair.assert_called_once_with() + page.repair_ship.assert_called_once_with('舰一') + goto_page.assert_called_once_with(ctx, PageName.MAIN) diff --git a/testing/ops/test_decisive_map_data.py b/testing/ops/test_decisive_map_data.py new file mode 100644 index 00000000..e9e9575f --- /dev/null +++ b/testing/ops/test_decisive_map_data.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from autowsgr.contracts.vessel_types import FLEET_VESSEL_TYPE_BY_CODE +from autowsgr.infra import load_yaml +from autowsgr.ops.decisive.config import MapData + + +DATA_DIR = ( + Path(__file__).resolve().parents[2] + / 'autowsgr' + / 'data' + / 'map' + / 'decisive_battle' + / 'silent_warrior' +) + +ENEMY_CODES = {code.upper() for code in FLEET_VESSEL_TYPE_BY_CODE} | {'AF'} + + +def test_silent_warrior_map_graph_contract() -> None: + paths = sorted(DATA_DIR.glob('EX-*.yaml')) + + assert len(paths) == 18 + maps = [load_yaml(path) for path in paths] + assert {path.stem for path in paths} == {map_data['map_id'] for map_data in maps} + assert sum(len(map_data['nodes']) for map_data in maps) == 319 + assert sum(len(node['next']) for map_data in maps for node in map_data['nodes'].values()) == 401 + + for map_data in maps: + map_id = map_data['map_id'] + chapter, stage = (int(value) for value in map_id.removeprefix('EX-').split('-')) + assert map_data['chapter'] == chapter + assert map_data['stage'] == stage + + nodes = map_data['nodes'] + labels = {node['label'] for node in nodes.values() if node['label'] != '0'} + assert set(map_data['enemy']) == labels + assert set(map_data['key_points']) <= labels + for formation in map_data['enemy'].values(): + assert formation + assert set(formation) <= ENEMY_CODES + + for node_id, node in nodes.items(): + assert all(next_id in nodes for next_id in node['next']) + if node_id != '0': + assert node['label'] == re.sub(r'\d+$', '', node_id) + + terminal_labels = { + node['label'] for node in nodes.values() if node['label'] != '0' and not node['next'] + } + assert len(terminal_labels) == 1 + + +def test_decisive_runtime_queries_use_map_files() -> None: + assert MapData.get_stage_end_node(6, 2) == 'J' + assert MapData.get_key_points(6, 2) == {'C', 'H', 'J'} + assert MapData.get_enemy(6, 1, 'J') == ['AF', 'BB', 'DD', 'BB', 'BC', 'AV'] + assert MapData.get_leftmost_choices(6, 2, '0') == ['A1', 'A2', 'A3'] + assert MapData.get_leftmost_choices(6, 2, 'A') == ['B1'] + assert MapData.get_leftmost_choices(6, 1, 'A') == ['B1', 'B2'] diff --git a/testing/ops/test_decisive_unit.py b/testing/ops/test_decisive_unit.py new file mode 100644 index 00000000..308bfc68 --- /dev/null +++ b/testing/ops/test_decisive_unit.py @@ -0,0 +1,1266 @@ +"""Focused tests for decisive map-entry timing.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, call + +import numpy as np +import pytest + +from autowsgr.ops.decisive import handlers +from autowsgr.ops.decisive.logic import DecisiveLogic +from autowsgr.types import DecisiveEntryStatus, DecisivePhase, FleetSelection +from autowsgr.ui.decisive import battle_page, map_controller, overlay, preparation +from autowsgr.ui.decisive.overlay import ( + ADVANCE_CARD_POSITIONS, + ADVANCE_CHOICE_ROI, + ADVANCE_CHOICE_THREE_ROI, + CONFIRM_EXIT_ROI, + FLEET_ACQUISITION_ROI, + CLICK_ADVANCE_CONFIRM, + FLEET_NAME_ROI, + USE_LAST_FLEET_ROI, + DecisiveOverlay, +) + + +def test_enter_map_uses_overlay_detection_instead_of_fixed_delay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Map entry transitions to recognition-driven waiting without a fixed sleep.""" + events: list[object] = [] + battle_page = MagicMock() + battle_page.detect_entry_status.return_value = DecisiveEntryStatus.CHALLENGING + battle_page.detect_stage.return_value = 1 + battle_page.click_enter_map.side_effect = lambda: events.append('enter') + battle_page_context = SimpleNamespace( + _battle_page=battle_page, + _config=SimpleNamespace(chapter=6), + _ctrl=MagicMock(), + _state=SimpleNamespace(stage=None, phase=None), + _use_last_fleet_attempts=1, + _wait_deadline=None, + _resume_mode=True, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + monkeypatch.setattr( + handlers.time, + 'sleep', + lambda delay: events.append(('sleep', delay)), + ) + + handlers.DecisivePhaseHandlers._handle_enter_map(battle_page_context) + + assert events == ['enter'] + assert battle_page_context._state.phase is DecisivePhase.WAITING_FOR_MAP + + +def test_choose_ships_uses_level2_when_no_level1_is_available() -> None: + """Backup ships are considered at every incomplete node, not only first_node.""" + config = SimpleNamespace(level1=['Primary'], level2=['Backup']) + state = SimpleNamespace(fleet=[''] * 7, score=10) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + {'Backup': FleetSelection('Backup', 4, (0.25, 0.5))}, + first_node=False, + ) + + assert result == ['Backup'] + + +def test_choose_ships_prioritizes_level1_before_level2() -> None: + """Primary ships consume the budget before backup ships are considered.""" + config = SimpleNamespace(level1=['Primary'], level2=['Backup']) + state = SimpleNamespace(fleet=[''] * 7, score=10) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + { + 'Primary': FleetSelection('Primary', 4, (0.25, 0.5)), + 'Backup': FleetSelection('Backup', 4, (0.375, 0.5)), + }, + first_node=False, + ) + + assert result == ['Primary', 'Backup'] + + +def test_choose_ships_first_node_preserves_two_ship_goal() -> None: + """First-node purchasing prefers an affordable two-ship bundle over one primary.""" + config = SimpleNamespace(level1=['Primary'], level2=['BackupA', 'BackupB']) + state = SimpleNamespace(fleet=[''] * 7, ships=set(), score=10) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + { + 'Primary': FleetSelection('Primary', 6, (0.25, 0.5)), + 'BackupA': FleetSelection('BackupA', 5, (0.375, 0.5)), + 'BackupB': FleetSelection('BackupB', 5, (0.5, 0.5)), + }, + first_node=True, + ) + + assert result == ['BackupA', 'BackupB'] + + +def test_choose_ships_first_node_prefers_primary_when_two_ship_bundle_fits() -> None: + """A 6+4 primary/backup bundle wins when it still reaches two ships.""" + config = SimpleNamespace(level1=['Primary'], level2=['Backup']) + state = SimpleNamespace(fleet=[''] * 7, ships=set(), score=10) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + { + 'Primary': FleetSelection('Primary', 6, (0.25, 0.5)), + 'Backup': FleetSelection('Backup', 4, (0.375, 0.5)), + }, + first_node=True, + ) + + assert result == ['Primary', 'Backup'] + + +def test_choose_ships_fills_pool_before_primary_upgrade() -> None: + """Later nodes spend on missing ships before upgrading existing primaries.""" + config = SimpleNamespace(level1=['Primary'], level2=['BackupA', 'BackupB']) + state = SimpleNamespace( + fleet=[''] * 7, + ships={'BackupA', 'OwnedA', 'OwnedB', 'OwnedC'}, + score=10, + ) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + { + 'Primary': FleetSelection('Primary', 6, (0.25, 0.5)), + 'BackupB': FleetSelection('BackupB', 4, (0.375, 0.5)), + 'BackupA': FleetSelection('BackupA', 4, (0.5, 0.5)), + }, + first_node=False, + ) + + assert result == ['Primary', 'BackupB'] + + +def test_choose_ships_upgrades_existing_primary_only() -> None: + """Once six ships exist, only an already-acquired primary may be upgraded.""" + config = SimpleNamespace(level1=['Primary'], level2=['Backup']) + state = SimpleNamespace( + fleet=[''] * 7, + ships={'Primary', 'Backup', 'Ship3', 'Ship4', 'Ship5', 'Ship6'}, + score=10, + ) + logic = DecisiveLogic(config, state) + + result = logic.choose_ships( + { + 'Primary': FleetSelection('Primary', 4, (0.25, 0.5)), + 'Backup': FleetSelection('Backup', 1, (0.375, 0.5)), + }, + first_node=False, + ) + + assert result == ['Primary'] + + +def test_best_fleet_keeps_current_damaged_ship_until_repair() -> None: + """A damaged ship already in formation stays in the decisive target fleet.""" + config = SimpleNamespace( + level1=['Primary'], + level2=['Backup'], + flagship_priority=[], + ) + state = SimpleNamespace( + fleet=['', 'Primary', 'Backup', '', '', '', ''], + ships={'Primary', 'Backup'}, + ) + ctx = SimpleNamespace(is_ship_available=lambda name: name != 'Primary') + logic = DecisiveLogic(config, state, ctx=ctx) + + assert logic.get_best_fleet() == ['', 'Primary', 'Backup', '', '', '', ''] + + +def test_refresh_entry_resets_before_entering_map( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A completed chapter resets first, then enters the refreshed map state.""" + events: list[str] = [] + battle_page = MagicMock() + battle_page.detect_entry_status.side_effect = [ + DecisiveEntryStatus.REFRESH, + DecisiveEntryStatus.REFRESHED, + ] + battle_page.reset_chapter.return_value = True + battle_page.detect_stage.return_value = 1 + battle_page.click_enter_map.side_effect = lambda: events.append('enter') + context = SimpleNamespace( + _battle_page=battle_page, + _config=SimpleNamespace(chapter=6), + _ctrl=MagicMock(), + _state=SimpleNamespace(stage=None, phase=None), + _use_last_fleet_attempts=1, + _skip_advance_choice=True, + _wait_deadline=None, + _resume_mode=True, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + + handlers.DecisivePhaseHandlers._handle_enter_map(context) + + battle_page.reset_chapter.assert_called_once_with() + assert battle_page.detect_entry_status.call_count == 2 + assert events == ['enter'] + assert context._state.phase is DecisivePhase.WAITING_FOR_MAP + + +def test_map_fallback_routes_to_prepare_without_state_guess( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A confirmed map page falls through to formation without guessing state.""" + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + map_controller_mock = MagicMock() + map_controller_mock.wait_for_entry_phase.return_value = DecisivePhase.PREPARE_COMBAT + context = SimpleNamespace( + _ctrl=SimpleNamespace(screenshot=lambda: screen), + _map=map_controller_mock, + _state=SimpleNamespace( + stage=1, + node='U', + phase=DecisivePhase.WAITING_FOR_MAP, + ), + _has_chosen_fleet=False, + _fleet_overlay_enabled=True, + _advance_source_node=None, + _use_last_fleet_attempts=0, + _skip_advance_choice=False, + _advance_choice_roi=lambda: None, + _wait_deadline=101.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + events: list[float] = [] + monkeypatch.setattr(handlers.time, 'sleep', events.append) + + handlers.DecisivePhaseHandlers._handle_waiting_for_map(context) + + map_controller_mock.wait_for_entry_phase.assert_called_once_with( + wait_for_use_last=True, + wait_for_advance=True, + wait_for_fleet=False, + timeout=3.0, + interval=0.2, + ) + assert context._state.phase is DecisivePhase.PREPARE_COMBAT + assert context._fleet_overlay_enabled is False + assert events == [0.05] + + +def test_new_entry_enables_fleet_after_advance_choice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A normal new entry enables fleet detection after choosing advance.""" + map_controller_mock = MagicMock() + map_controller_mock.wait_for_entry_phase.return_value = DecisivePhase.PREPARE_COMBAT + context = SimpleNamespace( + _ctrl=SimpleNamespace(screenshot=lambda: np.zeros((720, 1280, 3), dtype=np.uint8)), + _map=map_controller_mock, + _state=SimpleNamespace( + stage=1, + node='U', + phase=DecisivePhase.WAITING_FOR_MAP, + ), + _fleet_overlay_enabled=True, + _has_chosen_fleet=False, + _advance_source_node=None, + _use_last_fleet_attempts=0, + _skip_advance_choice=True, + _advance_choice_roi=lambda: None, + _wait_deadline=101.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + monkeypatch.setattr(handlers.time, 'sleep', lambda _delay: None) + + handlers.DecisivePhaseHandlers._handle_waiting_for_map(context) + + map_controller_mock.wait_for_entry_phase.assert_called_once_with( + wait_for_use_last=True, + wait_for_advance=False, + wait_for_fleet=True, + timeout=3.0, + interval=0.2, + ) + + +def test_full_recovery_keeps_fleet_check_enabled_without_advance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SL recovery keeps fleet detection enabled even when no advance popup appears.""" + map_controller_mock = MagicMock() + map_controller_mock.wait_for_entry_phase.return_value = DecisivePhase.PREPARE_COMBAT + context = SimpleNamespace( + _ctrl=SimpleNamespace(screenshot=lambda: np.zeros((720, 1280, 3), dtype=np.uint8)), + _map=map_controller_mock, + _state=SimpleNamespace( + stage=1, + node='U', + phase=DecisivePhase.WAITING_FOR_MAP, + ), + _fleet_overlay_enabled=True, + _full_recovery_check=True, + _has_chosen_fleet=False, + _use_last_fleet_attempts=0, + _skip_advance_choice=False, + _advance_source_node=None, + _advance_choice_roi=lambda: None, + _wait_deadline=101.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + monkeypatch.setattr(handlers.time, 'sleep', lambda _delay: None) + + handlers.DecisivePhaseHandlers._handle_waiting_for_map(context) + + map_controller_mock.wait_for_entry_phase.assert_called_once_with( + wait_for_use_last=True, + wait_for_advance=True, + wait_for_fleet=True, + timeout=3.0, + interval=0.2, + ) + assert context._fleet_overlay_enabled is True + + +def test_entry_phase_checks_overlays_before_map_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Entry recognition checks both optional overlays before accepting the map page.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + controller._wait_for_use_last_fleet = MagicMock(return_value=False) + controller._wait_for_advance_choice = MagicMock(return_value=False) + monkeypatch.setattr(map_controller, 'is_fleet_acquisition', lambda _screen: False) + monkeypatch.setattr(map_controller, 'is_decisive_map_page', lambda _screen: True) + + phase = controller.wait_for_entry_phase( + wait_for_use_last=True, + wait_for_advance=True, + timeout=3.0, + interval=0.2, + ) + + assert phase is DecisivePhase.PREPARE_COMBAT + controller._wait_for_advance_choice.assert_called_once_with( + None, + timeout=3.0, + interval=0.2, + ) + + +def test_entry_phase_can_skip_fleet_overlay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Initial entry can finish on the map without checking fleet acquisition.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + controller._wait_for_use_last_fleet = MagicMock(return_value=False) + controller._wait_for_advance_choice = MagicMock(return_value=False) + fleet_match = MagicMock(return_value=True) + monkeypatch.setattr(map_controller, 'is_fleet_acquisition', fleet_match) + monkeypatch.setattr(map_controller, 'is_decisive_map_page', lambda _screen: True) + + phase = controller.wait_for_entry_phase( + wait_for_use_last=False, + wait_for_advance=False, + wait_for_fleet=False, + ) + + assert phase is DecisivePhase.PREPARE_COMBAT + fleet_match.assert_not_called() + + +def test_decisive_overview_uses_entry_status_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Overview recognition searches the fixed entry-status button region.""" + calls: list[object] = [] + monkeypatch.setattr( + battle_page.ImageChecker, + 'find_any', + lambda _screen, _templates, **kwargs: calls.append(kwargs) + or SimpleNamespace(confidence=0.91), + ) + + result = battle_page.DecisiveBattlePage.is_current_page( + np.zeros((720, 1280, 3), dtype=np.uint8), + ) + + assert result.matched + assert calls[0]['roi'] is battle_page.ENTRY_STATUS_ROI + + +def test_recognize_stage_uses_one_based_map_stage_numbers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stage detection aligns its return values with EX--.yaml.""" + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + points = battle_page._STAGE_CHECK_POINTS[6] + white = battle_page._STAGE_CHECK_COLOR.as_rgb_tuple() + + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) == 0 + + for rx, ry in points[:1]: + screen[int(ry * 720), int(rx * 1280)] = white + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) == 1 + + for rx, ry in points[1:2]: + screen[int(ry * 720), int(rx * 1280)] = white + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) == 2 + + for rx, ry in points[2:]: + screen[int(ry * 720), int(rx * 1280)] = white + monkeypatch.setattr( + battle_page.ImageChecker, + 'template_exists', + lambda _screen, template, **_kwargs: template.name + in {'decisive_entry_challenging', 'decisive_reset_button'}, + ) + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) == 3 + + +def test_recognize_stage_uses_entry_status_for_three_existing_nodes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Three visible nodes need entry status to distinguish stage 3 from clear.""" + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + points = battle_page._STAGE_CHECK_POINTS[6] + color = battle_page._STAGE_CHECK_COLOR.as_rgb_tuple() + for rx, ry in points: + screen[int(ry * 720), int(rx * 1280)] = color + + def match_template(_screen: object, template: object, **_kwargs: object) -> bool: + return template is battle_page.Templates.Decisive.ENTRY_CHALLENGING or template is ( + battle_page.Templates.Decisive.RESET_BUTTON + ) + + monkeypatch.setattr(battle_page.ImageChecker, 'template_exists', match_template) + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) == 3 + + monkeypatch.setattr( + battle_page.ImageChecker, + 'template_exists', + lambda _screen, template, **_kwargs: template is battle_page.Templates.Decisive.ENTRY_REFRESH, + ) + assert battle_page.DecisiveBattlePage.recognize_stage(screen, 6) is None + + +def test_completed_chapter_does_not_enter_map( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A completed overview enters the existing chapter-clear phase.""" + battle_page = MagicMock() + battle_page.detect_entry_status.return_value = DecisiveEntryStatus.CHALLENGING + battle_page.detect_stage.return_value = None + context = SimpleNamespace( + _battle_page=battle_page, + _config=SimpleNamespace(chapter=6), + _ctrl=MagicMock(), + _state=SimpleNamespace(stage=0, phase=DecisivePhase.ENTER_MAP), + _resume_mode=True, + ) + + handlers.DecisivePhaseHandlers._handle_enter_map(context) + + assert context._state.phase is DecisivePhase.CHAPTER_CLEAR + battle_page.click_enter_map.assert_not_called() + + +def test_entry_status_uses_entry_status_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Chapter-entry status recognition uses the same fixed ROI.""" + page = object.__new__(battle_page.DecisiveBattlePage) + page._ctrl = MagicMock() + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + page._ctrl.screenshot.return_value = screen + calls: list[object] = [] + template_name = battle_page.Templates.Decisive.entry_status_templates()[1].name + monkeypatch.setattr( + battle_page.ImageChecker, + 'find_any', + lambda _screen, _templates, **kwargs: calls.append(kwargs) + or SimpleNamespace(template_name=template_name), + ) + + status = page.detect_entry_status(timeout=1.0) + + assert status is DecisiveEntryStatus.CHALLENGING + assert calls[0]['roi'] is battle_page.ENTRY_STATUS_ROI + + +def test_fleet_overlay_requires_fresh_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stale fleet overlay match must not re-enter the OCR phase.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + controller._ctrl.screenshot.return_value = screen + monkeypatch.setattr( + map_controller.ImageChecker, + 'template_exists', + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + map_controller, + 'detect_decisive_overlay', + MagicMock(side_effect=[DecisiveOverlay.FLEET_ACQUISITION, None]), + ) + monkeypatch.setattr(map_controller, 'is_fleet_acquisition', lambda _screen: False) + monkeypatch.setattr(map_controller, 'is_decisive_map_page', lambda _screen: True) + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + + assert controller.detect_decisive_phase() is DecisivePhase.PREPARE_COMBAT + + +def test_advance_choice_overlay_uses_fixed_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Advance-choice matching is restricted to the annotated card region.""" + calls: list[object] = [] + + def template_exists( + _screen: object, _template: object, *, roi: object, confidence: float + ) -> bool: + calls.append((roi, confidence)) + return len(calls) == 3 + + monkeypatch.setattr(overlay.ImageChecker, 'template_exists', template_exists) + + assert overlay.detect_decisive_overlay(np.zeros((720, 1280, 3), dtype=np.uint8)) is ( + DecisiveOverlay.ADVANCE_CHOICE + ) + assert calls == [ + (FLEET_ACQUISITION_ROI, 0.70), + (CONFIRM_EXIT_ROI, 0.85), + (ADVANCE_CHOICE_ROI, 0.80), + ] + + +def test_fleet_overlay_uses_fixed_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fleet acquisition matching is restricted to the annotated title ROI.""" + calls: list[tuple[object, float]] = [] + + def template_exists( + _screen: object, _template: object, *, roi: object, confidence: float + ) -> bool: + calls.append((roi, confidence)) + return True + + monkeypatch.setattr(overlay.ImageChecker, 'template_exists', template_exists) + + assert overlay.detect_decisive_overlay(np.zeros((720, 1280, 3), dtype=np.uint8)) is ( + DecisiveOverlay.FLEET_ACQUISITION + ) + assert calls == [(FLEET_ACQUISITION_ROI, 0.70)] + + +def test_advance_choice_overlay_tries_three_branch_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The fallback detector also checks the annotated three-card ROI.""" + calls: list[object] = [] + + def template_exists( + _screen: object, _template: object, *, roi: object, confidence: float + ) -> bool: + calls.append((roi, confidence)) + return roi is ADVANCE_CHOICE_THREE_ROI + + monkeypatch.setattr(overlay.ImageChecker, 'template_exists', template_exists) + + assert overlay.detect_decisive_overlay(np.zeros((720, 1280, 3), dtype=np.uint8)) is ( + DecisiveOverlay.ADVANCE_CHOICE + ) + assert calls[-1] == (ADVANCE_CHOICE_THREE_ROI, 0.80) + + +def test_advance_choice_explicit_roi_falls_back_to_two_card_roi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A graph-selected three-card ROI still accepts a live two-card popup.""" + calls: list[object] = [] + + def template_exists( + _screen: object, _template: object, *, roi: object, confidence: float + ) -> bool: + calls.append((roi, confidence)) + return roi is ADVANCE_CHOICE_ROI + + monkeypatch.setattr(overlay.ImageChecker, 'template_exists', template_exists) + + assert overlay.detect_decisive_overlay( + np.zeros((720, 1280, 3), dtype=np.uint8), + advance_choice_roi=ADVANCE_CHOICE_THREE_ROI, + ) is DecisiveOverlay.ADVANCE_CHOICE + assert calls[-2:] == [ + (ADVANCE_CHOICE_THREE_ROI, 0.80), + (ADVANCE_CHOICE_ROI, 0.80), + ] + + +def test_node_result_timeout_keeps_waiting_for_late_overlay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A late post-combat advance popup must not fall through to formation.""" + context = SimpleNamespace( + _logic=SimpleNamespace(is_stage_end=lambda: False), + _map=SimpleNamespace( + detect_decisive_phase=MagicMock(return_value=DecisivePhase.PREPARE_COMBAT) + ), + _state=SimpleNamespace( + node='A', + stage=1, + phase=DecisivePhase.NODE_RESULT, + ), + _fleet_overlay_enabled=False, + _POST_COMBAT_TIMEOUT=0.0, + _wait_deadline=0.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + + handlers.DecisivePhaseHandlers._handle_node_result(context) + + assert context._state.node == 'B' + assert context._state.phase is DecisivePhase.WAITING_FOR_MAP + assert context._wait_deadline == 110.0 + + +def test_non_terminal_node_result_enables_fleet_overlay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-terminal result enables fleet detection for the next node.""" + detect_phase = MagicMock(return_value=DecisivePhase.CHOOSE_FLEET) + context = SimpleNamespace( + _logic=SimpleNamespace(is_stage_end=lambda: False), + _map=SimpleNamespace(detect_decisive_phase=detect_phase), + _state=SimpleNamespace( + node='A', + stage=1, + phase=DecisivePhase.NODE_RESULT, + ), + _fleet_overlay_enabled=False, + _advance_choice_roi=lambda: None, + _POST_COMBAT_TIMEOUT=1.0, + _POST_COMBAT_INTERVAL=0.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + monkeypatch.setattr(handlers.time, 'sleep', lambda _delay: None) + + handlers.DecisivePhaseHandlers._handle_node_result(context) + + assert context._fleet_overlay_enabled is True + detect_phase.assert_called_once_with( + advance_choice_roi=None, + allow_fleet_overlay=True, + ) + assert context._state.phase is DecisivePhase.CHOOSE_FLEET + + +def test_terminal_node_result_disables_fleet_overlay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A terminal result enters stage clear without enabling fleet detection.""" + context = SimpleNamespace( + _logic=SimpleNamespace(is_stage_end=lambda: True), + _state=SimpleNamespace( + node='J', + stage=1, + phase=DecisivePhase.NODE_RESULT, + ), + _fleet_overlay_enabled=True, + ) + + handlers.DecisivePhaseHandlers._handle_node_result(context) + + assert context._fleet_overlay_enabled is False + assert context._state.phase is DecisivePhase.STAGE_CLEAR + + +def test_stage_clear_reanchors_next_subsection_from_unknown_node() -> None: + """A new subsection starts at U so its first live node is recognized.""" + context = SimpleNamespace( + _map=SimpleNamespace(confirm_stage_clear=MagicMock(return_value=[])), + _state=SimpleNamespace( + stage=1, + node='J', + phase=DecisivePhase.STAGE_CLEAR, + ), + _advance_source_node='J', + _resume_mode=False, + _fleet_overlay_enabled=False, + ) + + handlers.DecisivePhaseHandlers._handle_stage_clear(context) + + assert context._state.node == 'U' + assert context._advance_source_node is None + assert context._resume_mode is True + assert context._fleet_overlay_enabled is True + assert context._state.phase is DecisivePhase.ENTER_MAP + + +def test_temporary_leave_disables_fleet_overlay_for_reentry() -> None: + """A leave/re-entry context suppresses fleet detection until another result.""" + map_controller = SimpleNamespace( + open_retreat_dialog=MagicMock(), + confirm_leave=MagicMock(), + ) + context = SimpleNamespace( + _map=map_controller, + _fleet_overlay_enabled=True, + ) + + handlers.DecisivePhaseHandlers._execute_leave(context) + + assert context._fleet_overlay_enabled is False + map_controller.open_retreat_dialog.assert_called_once_with() + map_controller.confirm_leave.assert_called_once_with() + + +def test_retreat_reenables_fleet_overlay_for_reentry() -> None: + """A retreat starts a fresh entry path where fleet detection is allowed.""" + map_controller = SimpleNamespace( + open_retreat_dialog=MagicMock(), + confirm_retreat=MagicMock(), + ) + context = SimpleNamespace( + _map=map_controller, + _fleet_overlay_enabled=False, + ) + + handlers.DecisivePhaseHandlers._execute_retreat(context) + + assert context._fleet_overlay_enabled is True + map_controller.open_retreat_dialog.assert_called_once_with() + map_controller.confirm_retreat.assert_called_once_with() + + +def test_choose_fleet_commits_state_only_after_purchase_and_close() -> None: + """Fleet state is committed only after a purchase and successful close.""" + selection = SimpleNamespace(click_position=(0.25, 0.5)) + close = MagicMock(return_value=True) + buy = MagicMock() + context = SimpleNamespace( + _has_chosen_fleet=False, + _recognize_fleet_options_with_retry=MagicMock( + return_value=(np.zeros((720, 1280, 3), dtype=np.uint8), 10, {'Ship': selection}) + ), + _state=SimpleNamespace( + score=10, + ships=set(), + phase=DecisivePhase.CHOOSE_FLEET, + is_begin=lambda: False, + ), + _logic=SimpleNamespace(choose_ships=lambda _selections, first_node: ['Ship']), + _map=SimpleNamespace( + close_fleet_overlay=close, + buy_fleet_option=buy, + refresh_fleet=MagicMock(), + ), + ) + + handlers.DecisivePhaseHandlers._handle_choose_fleet(context) + + assert context._has_chosen_fleet is True + assert context._force_fleet_scan is False + assert context._state.phase is DecisivePhase.PREPARE_COMBAT + buy.assert_called_once_with(selection.click_position) + close.assert_called_once_with() + + +def test_choose_fleet_without_purchase_defers_to_sufficiency_check() -> None: + """An empty purchase decision closes and lets preparation judge sufficiency.""" + close = MagicMock(return_value=True) + buy = MagicMock() + context = SimpleNamespace( + _has_chosen_fleet=False, + _recognize_fleet_options_with_retry=MagicMock( + return_value=(np.zeros((720, 1280, 3), dtype=np.uint8), 10, {}) + ), + _state=SimpleNamespace( + score=10, + ships=set(), + phase=DecisivePhase.CHOOSE_FLEET, + is_begin=lambda: False, + ), + _logic=SimpleNamespace(choose_ships=MagicMock()), + _map=SimpleNamespace( + close_fleet_overlay=close, + buy_fleet_option=buy, + refresh_fleet=MagicMock(), + ), + ) + + handlers.DecisivePhaseHandlers._handle_choose_fleet(context) + + assert context._has_chosen_fleet is True + assert context._force_fleet_scan is True + assert context._state.phase is DecisivePhase.PREPARE_COMBAT + close.assert_called_once_with() + buy.assert_not_called() + + +def test_choose_fleet_does_not_buy_unconfigured_card() -> None: + """Unconfigured OCR cards are not valid substitutes for primary/backup ships.""" + selection = SimpleNamespace(name='Unconfigured', cost=1, click_position=(0.25, 0.5)) + close = MagicMock(return_value=True) + buy = MagicMock() + context = SimpleNamespace( + _has_chosen_fleet=False, + _recognize_fleet_options_with_retry=MagicMock( + return_value=(np.zeros((720, 1280, 3), dtype=np.uint8), 10, {'Cheap': selection}) + ), + _state=SimpleNamespace( + score=10, + ships=set(), + phase=DecisivePhase.CHOOSE_FLEET, + is_begin=lambda: False, + ), + _logic=SimpleNamespace(choose_ships=lambda _selections, first_node: []), + _map=SimpleNamespace( + close_fleet_overlay=close, + buy_fleet_option=buy, + refresh_fleet=MagicMock(), + ), + ) + + handlers.DecisivePhaseHandlers._handle_choose_fleet(context) + + buy.assert_not_called() + assert context._state.ships == set() + assert context._force_fleet_scan is True + assert context._state.phase is DecisivePhase.PREPARE_COMBAT + close.assert_called_once_with() + + +def test_choose_fleet_falls_back_to_low_cost_ship_when_empty_close_fails() -> None: + """An empty configured purchase selects one real ship, closes, then retreats.""" + ship = SimpleNamespace(name='Unconfigured', cost=4, click_position=(0.25, 0.5)) + skill = SimpleNamespace(name='长跑训练', cost=1, click_position=(0.375, 0.5)) + close = MagicMock(side_effect=[False, True]) + buy = MagicMock() + context = SimpleNamespace( + _has_chosen_fleet=False, + _recognize_fleet_options_with_retry=MagicMock( + return_value=( + np.zeros((720, 1280, 3), dtype=np.uint8), + 10, + {'Unconfigured': ship, '长跑训练': skill}, + ) + ), + _state=SimpleNamespace( + score=10, + ships=set(), + phase=DecisivePhase.CHOOSE_FLEET, + is_begin=lambda: True, + ), + _logic=SimpleNamespace(choose_ships=lambda _selections, first_node: []), + _map=SimpleNamespace( + close_fleet_overlay=close, + buy_fleet_option=buy, + refresh_fleet=MagicMock(), + detect_last_offer_name=MagicMock(return_value=None), + ), + ) + + handlers.DecisivePhaseHandlers._handle_choose_fleet(context) + + buy.assert_called_once_with(ship.click_position) + assert context._state.ships == {'Unconfigured'} + assert context._state.phase is DecisivePhase.RETREAT + assert close.call_count == 2 + + +def test_close_fleet_overlay_waits_for_stable_map_frame( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A confirmed title disappearance adds the broad post-close settle wait.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + controller._ctrl.screenshot.return_value = np.zeros((720, 1280, 3), dtype=np.uint8) + monkeypatch.setattr(map_controller, 'is_fleet_acquisition', lambda _screen: False) + monkeypatch.setattr(map_controller.time, 'monotonic', lambda: 0.0) + sleeps: list[float] = [] + monkeypatch.setattr(map_controller.time, 'sleep', sleeps.append) + + assert controller.close_fleet_overlay() is True + assert 1.5 in sleeps + + +def test_combat_success_clicks_result_page_before_node_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful decisive combat dismisses the result page before polling.""" + result = SimpleNamespace( + flag=handlers.ConditionFlag.OPERATION_SUCCESS, + ship_stats=[], + ) + context = SimpleNamespace( + _ctx=SimpleNamespace(ctrl=MagicMock()), + _ctrl=MagicMock(), + _logic=SimpleNamespace(get_formation=lambda: 'single_column', is_key_point=lambda: False), + _state=SimpleNamespace(node='A', stage=1, ship_stats=[]), + _sync_ship_states=MagicMock(), + ) + run_combat = MagicMock(return_value=result) + click_result = MagicMock() + sleeps: list[float] = [] + monkeypatch.setattr(handlers, 'run_combat', run_combat) + monkeypatch.setattr(handlers, 'click_result', click_result) + monkeypatch.setattr(handlers.time, 'sleep', sleeps.append) + + handlers.DecisivePhaseHandlers._handle_combat(context) + + click_result.assert_called_once_with(context._ctrl) + assert context._state.phase is DecisivePhase.NODE_RESULT + assert 0.3 in sleeps + + +def test_use_last_fleet_checks_fixed_roi_three_times( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The optional last-fleet stage settles, then performs three ROI checks.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + calls: list[tuple[object, float]] = [] + monkeypatch.setattr( + map_controller.ImageChecker, + 'template_exists', + lambda _screen, _template, *, roi, confidence: calls.append((roi, confidence)) or False, + ) + events: list[float] = [] + monkeypatch.setattr(map_controller.time, 'sleep', events.append) + + assert controller._wait_for_use_last_fleet() is False + assert calls == [(USE_LAST_FLEET_ROI, 0.8)] * 3 + assert events == [3.0, 0.2, 0.2, 0.2] + + +def test_reset_chapter_requires_recognized_reset_button( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reset opens confirmation only after its entry button is positively matched.""" + page = object.__new__(battle_page.DecisiveBattlePage) + page._ctrl = MagicMock() + match = SimpleNamespace(center=(0.75, 0.9)) + find_template = MagicMock(return_value=match) + monkeypatch.setattr( + battle_page.ImageChecker, + 'find_template', + find_template, + ) + monkeypatch.setattr( + battle_page.ImageChecker, + 'template_exists', + lambda *_args, **_kwargs: False, + ) + confirm = MagicMock() + monkeypatch.setattr(battle_page, 'confirm_operation', confirm) + monkeypatch.setattr(battle_page.time, 'sleep', lambda _delay: None) + + assert page.reset_chapter() is True + page._ctrl.click.assert_called_once_with(*match.center) + assert find_template.call_args.kwargs['roi'] == battle_page.RESET_BUTTON_ROI + confirm.assert_called_once_with(page._ctrl, must_confirm=True, timeout=5.0) + + +def test_reset_chapter_falls_back_to_refresh_entry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The completed-state reset button is the bottom-center entry template.""" + page = object.__new__(battle_page.DecisiveBattlePage) + page._ctrl = MagicMock() + match = SimpleNamespace(center=(0.53, 0.93)) + find_template = MagicMock(side_effect=[None, match]) + monkeypatch.setattr(battle_page.ImageChecker, 'find_template', find_template) + monkeypatch.setattr( + battle_page.ImageChecker, + 'template_exists', + lambda *_args, **_kwargs: False, + ) + confirm = MagicMock() + monkeypatch.setattr(battle_page, 'confirm_operation', confirm) + monkeypatch.setattr(battle_page.time, 'sleep', lambda _delay: None) + + assert page.reset_chapter() is True + page._ctrl.click.assert_called_once_with(*match.center) + assert find_template.call_args_list[1].kwargs['roi'] == battle_page.RESET_ENTRY_ROI + confirm.assert_called_once_with(page._ctrl, must_confirm=True, timeout=5.0) + + +def test_reset_chapter_retries_entry_when_confirmation_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missed confirmation retries only after re-matching the reset entry.""" + page = object.__new__(battle_page.DecisiveBattlePage) + page._ctrl = MagicMock() + match = SimpleNamespace(center=(0.684, 0.932)) + monkeypatch.setattr( + battle_page.ImageChecker, + 'find_template', + MagicMock(side_effect=[match, match]), + ) + monkeypatch.setattr( + battle_page.ImageChecker, + 'template_exists', + lambda *_args, **_kwargs: False, + ) + confirm = MagicMock(side_effect=[battle_page.NavigationError('missing confirmation'), None]) + monkeypatch.setattr(battle_page, 'confirm_operation', confirm) + monkeypatch.setattr(battle_page.time, 'sleep', lambda _delay: None) + + assert page.reset_chapter() is True + assert page._ctrl.click.call_count == 2 + assert confirm.call_count == 2 + + +def test_enter_formation_retries_after_fleet_name_miss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing decisive fleet title causes one map-back and formation retry.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + controller._wait_for_fleet_name = MagicMock(side_effect=[False, True]) + controller.go_to_map_page = MagicMock() + clicks: list[object] = [] + monkeypatch.setattr( + map_controller, + 'click_and_wait_for_page', + lambda *_args, **_kwargs: clicks.append(True), + ) + monkeypatch.setattr( + map_controller.ImageChecker, + 'template_exists', + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr(map_controller, 'is_decisive_map_page', lambda _screen: True) + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + + controller.enter_formation() + + assert len(clicks) == 2 + controller.go_to_map_page.assert_called_once_with() + + +def test_enter_formation_refuses_unrecognized_page( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Formation must not click when the current page is not the decisive map.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + clicks: list[object] = [] + monkeypatch.setattr( + map_controller, + 'click_and_wait_for_page', + lambda *_args, **_kwargs: clicks.append(True), + ) + monkeypatch.setattr(map_controller, 'is_decisive_map_page', lambda _screen: False) + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + + with pytest.raises(TimeoutError, match='未识别到决战地图页'): + controller.enter_formation() + + assert clicks == [] + + +def test_decisive_preparation_go_back_uses_decisive_map_checker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Decisive preparation return waits for the decisive map recognizer.""" + page = object.__new__(preparation.DecisiveBattlePreparationPage) + page._ctrl = MagicMock() + calls: list[dict[str, object]] = [] + monkeypatch.setattr( + preparation, + 'click_and_wait_for_page', + lambda ctrl, **kwargs: calls.append({'ctrl': ctrl, **kwargs}), + ) + + page.go_back() + + assert calls == [ + { + 'ctrl': page._ctrl, + 'click_coord': preparation.CLICK_BACK, + 'checker': preparation.is_decisive_map_page, + 'source': preparation.PageName.BATTLE_PREP, + 'target': preparation.PageName.MAP, + } + ] + + +def test_decisive_map_recognizer_uses_pixel_signature() -> None: + """The decisive map recognizer accepts its existing pixel signature.""" + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + for rule in overlay.SIG_MAP_PAGE.rules: + screen[int(rule.y * screen.shape[0]), int(rule.x * screen.shape[1])] = ( + rule.color.as_rgb_tuple() + ) + + assert overlay.is_decisive_map_page(screen) + + first_rule = overlay.SIG_MAP_PAGE.rules[0] + screen[int(first_rule.y * screen.shape[0]), int(first_rule.x * screen.shape[1])] = 0 + assert not overlay.is_decisive_map_page(screen) + + +def test_fleet_name_checks_fixed_roi_three_times( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The decisive formation title uses three fixed-ROI template checks.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + calls: list[tuple[object, float]] = [] + monkeypatch.setattr( + map_controller.ImageChecker, + 'template_exists', + lambda _screen, _template, *, roi, confidence: calls.append((roi, confidence)) or False, + ) + sleeps: list[float] = [] + monkeypatch.setattr(map_controller.time, 'sleep', sleeps.append) + + assert controller._wait_for_fleet_name() is False + assert calls == [(FLEET_NAME_ROI, 0.8)] * 3 + assert sleeps == [0.2, 0.2, 0.2] + + +def test_select_advance_card_requires_recognized_overlay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Advance clicks are gated by a positive ADVANCE_CHOICE match.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + controller.wait_for_overlay = MagicMock() + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + + controller.select_advance_card(0) + + controller.wait_for_overlay.assert_called_once_with( + DecisiveOverlay.ADVANCE_CHOICE, + timeout=5.0, + interval=0.2, + ) + assert controller._ctrl.click.call_args_list == [ + call(*ADVANCE_CARD_POSITIONS[0]), + call(*CLICK_ADVANCE_CONFIRM), + ] + + +def test_use_last_fleet_refuses_unrecognized_click( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The use-last-fleet path must fail closed when its template is absent.""" + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctrl = MagicMock() + clock = iter((0.0, 6.0)) + monkeypatch.setattr(map_controller.time, 'monotonic', lambda: next(clock)) + + with pytest.raises(TimeoutError, match='未识别到'): + controller.click_use_last_fleet() + + controller._ctrl.click.assert_not_called() + + +def test_check_fleet_skips_ship_pool_when_current_formation_has_ships( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-empty formation is checked without opening the ship pool.""" + fleet = ['U-47', None, None, None, None, None] + page = MagicMock() + page.detect_fleet.return_value = fleet + page.detect_ship_damage.return_value = {} + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctx = object() + controller._config = object() + controller._ocr = object() + controller._ctrl = MagicMock() + controller._ctrl.screenshot.return_value = np.zeros((720, 1280, 3), dtype=np.uint8) + controller.enter_formation = MagicMock() + + monkeypatch.setattr( + map_controller, + 'DecisiveBattlePreparationPage', + lambda *_args: page, + ) + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + recognize = MagicMock() + monkeypatch.setattr(map_controller, '_recognize_ships', recognize) + + result = controller.check_fleet() + + assert result == (fleet, {}, {'U-47'}) + controller.enter_formation.assert_called_once_with() + page.click_ship_slot.assert_not_called() + page.go_back.assert_not_called() + recognize.assert_not_called() + + +def test_check_fleet_forces_ship_pool_scan_for_full_recovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Full recovery rebuilds the ship context even with a non-empty formation.""" + fleet = ['U-47', None, None, None, None, None] + page = MagicMock() + page.detect_fleet.return_value = fleet + page.detect_ship_damage.return_value = {} + controller = object.__new__(map_controller.DecisiveMapController) + controller._ctx = object() + controller._config = object() + controller._ocr = object() + controller._ctrl = MagicMock() + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + controller._ctrl.screenshot.side_effect = [screen, screen, screen] + controller.enter_formation = MagicMock() + + monkeypatch.setattr( + map_controller, + 'DecisiveBattlePreparationPage', + lambda *_args: page, + ) + monkeypatch.setattr( + map_controller.BattlePreparationPage, + 'is_current_page', + lambda _screen: False, + ) + monkeypatch.setattr(map_controller.time, 'sleep', lambda _delay: None) + recognize = MagicMock(return_value={'U-1206'}) + monkeypatch.setattr(map_controller, '_recognize_ships', recognize) + + result = controller.check_fleet(scan_ship_pool=True) + + assert result == (fleet, {}, {'U-47', 'U-1206'}) + page.click_ship_slot.assert_called_once_with(0) + recognize.assert_called_once() + page.go_back.assert_not_called() + + +def test_advance_choice_waits_for_next_recognized_phase( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """After choosing a card, the next phase comes from fresh screen recognition.""" + context = SimpleNamespace( + _logic=SimpleNamespace(), + _map=SimpleNamespace(select_advance_card=MagicMock()), + _state=SimpleNamespace(phase=DecisivePhase.ADVANCE_CHOICE), + _advance_choice_roi=lambda: None, + _wait_deadline=0.0, + ) + monkeypatch.setattr(handlers.time, 'monotonic', lambda: 100.0) + + handlers.DecisivePhaseHandlers._handle_advance_choice(context) + + context._map.select_advance_card.assert_called_once_with(0) + assert context._state.phase is DecisivePhase.WAITING_FOR_MAP + assert context._wait_deadline == 110.0 diff --git a/testing/ops/test_destroy_unit.py b/testing/ops/test_destroy_unit.py index b486d490..07ceac2d 100644 --- a/testing/ops/test_destroy_unit.py +++ b/testing/ops/test_destroy_unit.py @@ -1,7 +1,8 @@ """destroy_ships_auto 模式调度单元测试 (无设备)。 -验证 disable / include / exclude 三种工作模式 + remove_equipment 的派发逻辑。 -通过 monkeypatch 拦截 ``destroy_ships``, 不触发真实导航 / IO。 +验证 disable / include / exclude 三种工作模式 + remove_equipment 的派发逻辑, +以及 ``from_dialog`` 弹窗直达路线 (点弹窗「解装」直达建造页后复用 +destroy_ships)。通过 monkeypatch 拦截导航 / UI 层, 不触发真实 IO。 """ from __future__ import annotations @@ -9,7 +10,8 @@ import pytest from autowsgr.ops import destroy as destroy_module -from autowsgr.types import DestroyShipWorkMode, ShipType +from autowsgr.ops.destroy import CLICK_DOCK_DIALOG_DESTROY +from autowsgr.types import DestroyShipWorkMode, PageName, ShipType class _FakeConfig: @@ -29,6 +31,7 @@ def __init__( class _FakeCtx: def __init__(self, cfg: _FakeConfig) -> None: self.config = cfg + self.ctrl = None @pytest.fixture @@ -99,3 +102,83 @@ def test_exclude_all_types_returns_false(recorded: list[dict]): ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.exclude, types=all_real)) assert destroy_ships_auto(ctx) is False assert recorded == [] + + +# ───────────────────────────────────────────── +# from_dialog 弹窗直达路线 +# ───────────────────────────────────────────── + + +class TestFromDialogDispatch: + """destroy_ships_auto(from_dialog=True): 点弹窗「解装」直达, 再复用 destroy_ships。""" + + def test_from_dialog_clicks_dialog_then_reuses_destroy_ships( + self, monkeypatch: pytest.MonkeyPatch + ): + from autowsgr.ops.destroy import destroy_ships_auto + + calls: list[tuple] = [] + monkeypatch.setattr( + destroy_module, + 'click_and_wait_for_page', + lambda _ctrl, *, click_coord, source, target, **_k: calls.append( + ('dialog', click_coord, source, target) + ), + ) + monkeypatch.setattr( + destroy_module, + 'destroy_ships', + lambda _ctx, *, ship_types, remove_equipment: calls.append( + ('destroy', ship_types, remove_equipment) + ), + ) + + types = [ShipType.DD, ShipType.CL] + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.include, types=types, remove_eq=False)) + assert destroy_ships_auto(ctx, from_dialog=True) is True + # 先点弹窗「解装」直达建造页, 再复用 destroy_ships (其 goto_page 幂等直达) + assert calls == [ + ('dialog', CLICK_DOCK_DIALOG_DESTROY, '船坞满弹窗', PageName.BUILD), + ('destroy', types, False), + ] + + def test_default_skips_dialog_entry(self, monkeypatch: pytest.MonkeyPatch): + """from_dialog=False (默认): 不点弹窗, 直接全局导航 destroy_ships。""" + from autowsgr.ops.destroy import destroy_ships_auto + + calls: list[tuple] = [] + monkeypatch.setattr( + destroy_module, + 'click_and_wait_for_page', + lambda *_a, **_k: calls.append(('dialog',)), + ) + monkeypatch.setattr( + destroy_module, + 'destroy_ships', + lambda *_a, **_k: calls.append(('destroy',)), + ) + + ctx = _FakeCtx(_FakeConfig(DestroyShipWorkMode.disable)) + assert destroy_ships_auto(ctx) is True + assert calls == [('destroy',)] + + def test_from_dialog_exhausted_whitelist_skips_all(self, monkeypatch: pytest.MonkeyPatch): + """白名单覆盖全部舰种 → 弹窗不点、解装不执行。""" + from autowsgr.ops.destroy import destroy_ships_auto + + calls: list[tuple] = [] + monkeypatch.setattr( + destroy_module, + 'click_and_wait_for_page', + lambda *_a, **_k: calls.append(('dialog',)), + ) + monkeypatch.setattr( + destroy_module, + 'destroy_ships', + lambda *_a, **_k: calls.append(('destroy',)), + ) + + 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, from_dialog=True) is False + assert calls == [] diff --git a/testing/ops/test_launcher_unit.py b/testing/ops/test_launcher_unit.py index f32048e3..c012c81f 100644 --- a/testing/ops/test_launcher_unit.py +++ b/testing/ops/test_launcher_unit.py @@ -3,9 +3,31 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest + from autowsgr.scheduler.launcher import Launcher +def test_launch_disconnects_controller_after_connect_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A partially connected controller is released when launch fails.""" + launcher = Launcher() + ctrl = MagicMock() + + def fail_connect() -> None: + launcher._ctrl = ctrl + raise RuntimeError('connect failed') + + monkeypatch.setattr(launcher, 'load_config', lambda: None) + monkeypatch.setattr(launcher, 'connect', fail_connect) + + with pytest.raises(RuntimeError, match='connect failed'): + launcher.launch() + + ctrl.disconnect.assert_called_once_with() + + def _launcher_with_enhanced_ship_ocr(enabled: bool) -> Launcher: launcher = Launcher() launcher.set_config( @@ -26,13 +48,135 @@ def test_create_ship_ocr_disabled_uses_default_easyocr(): def test_create_ship_ocr_enabled_uses_fastocr(): + """enhanced_ship_ocr=True 时默认 OCR 已是 FastOCR, ship_ocr 返回 None。""" launcher = _launcher_with_enhanced_ship_ocr(True) - fastocr = MagicMock() + assert launcher.create_ship_ocr() is None + + +def test_create_ocr_missing_enhanced_ship_ocr_attr_no_throw(): + """cfg.ocr 没有 enhanced_ship_ocr 属性时,向后兼容回退到 EasyOCR,不抛 AttributeError。 + + 这是 GUI 运行契约探针 (backendContractProbe.ts) 的核心兼容场景: + 探针构造的 SimpleNamespace 仅包含 gpu / mirror / ship_name_match_* 字段, + 故意不含 enhanced_ship_ocr,因此 ShiinaKuroko 的全局 FastOCR 开关必须用 + getattr 默认值,避免探针抛异常导致 GUI 错误地把后端判定为 "缺少 + autowsgr-runtime-contract 包" → 反复重装 → 死循环。 + """ + # 完全复刻 GUI 探针构造的字段集合:缺 enhanced_ship_ocr + ocr_cfg = SimpleNamespace( + gpu=False, + mirror='modelscope', + ship_name_match_confidence=0.0, + ship_name_corrections={}, + ship_name_aliases={}, + ) + launcher = Launcher() + launcher.set_config(SimpleNamespace(ocr=ocr_cfg)) + + seen_gpu = [] + + def _capture_easy_ocr(*args, **kwargs): + seen_gpu.append(kwargs.get('gpu')) + return MagicMock() with patch( + 'autowsgr.scheduler.launcher.EasyOCREngine.create', + side_effect=_capture_easy_ocr, + ) as easy_create, patch( 'autowsgr.scheduler.launcher.OCREngine.create', - return_value=fastocr, - ) as create: - assert launcher.create_ship_ocr() is fastocr + ) as fastocr_create, patch.dict( + 'os.environ', {'AUTOWSGR_OCR_GPU_MODE': 'cuda'}, + clear=False, + ): + launcher.create_ocr() + + fastocr_create.assert_not_called() + easy_create.assert_called_once() + assert seen_gpu == [True], ( + 'AUTOWSGR_OCR_GPU_MODE=cuda 应覆盖 cfg.ocr.gpu=False,' + f'但 EasyOCREngine.create 收到 gpu={seen_gpu}' + ) + + # create_ship_ocr 也必须不抛 AttributeError + with patch('autowsgr.scheduler.launcher.OCREngine.create'): + assert launcher.create_ship_ocr() is None + + +def test_gui_runtime_contract_probe_compatible(): + """端到端复刻 GUI backendContractProbe.ts 定义的 _verify_gui_runtime_contract()。 + + 通过则意味着 GUI 的 envCheck 不会把 runtime_contract 标记为 False, + 也就不会出现日志里反复触发 "强制更新当前通道 autowsgr → 装伪包 + autowsgr-runtime-contract → 阿里云源断流 IncompleteRead" 的死循环。 + """ + import os + + log_cfg = SimpleNamespace( + dir='.', + level='INFO', + effective_channels=[], + ) + ocr_cfg = SimpleNamespace( + gpu=False, + mirror='modelscope', + ship_name_match_confidence=0.0, + ship_name_corrections={}, + ship_name_aliases={}, + ) + fake_config = SimpleNamespace(log=log_cfg, ocr=ocr_cfg) + + save_images_calls = [] + + def _capture_setup_logger(*args, **kwargs): + save_images_calls.append(kwargs.get('save_images')) + + ocr_gpu_calls = [] + + def _capture_easy_create(*args, **kwargs): + ocr_gpu_calls.append(kwargs.get('gpu')) + return MagicMock() + + save_key = 'AUTOWSGR_SAVE_IMAGES' + gpu_key = 'AUTOWSGR_OCR_GPU_MODE' + previous_save = os.environ.get(save_key) + previous_gpu = os.environ.get(gpu_key) + try: + with patch( + 'autowsgr.scheduler.launcher.ConfigManager.load', + return_value=fake_config, + ), patch( + 'autowsgr.scheduler.launcher.setup_logger', + side_effect=_capture_setup_logger, + ): + os.environ[save_key] = 'true' + Launcher().load_config() + os.environ[save_key] = 'false' + Launcher().load_config() + assert save_images_calls == [True, False], ( + 'AUTOWSGR_SAVE_IMAGES 行为不兼容 (GUI probe step 1 fail) ' + f'actual={save_images_calls}' + ) - create.assert_called_once_with(engine='fastocr', gpu=False) + launcher = Launcher() + launcher.set_config(fake_config) + with patch( + 'autowsgr.scheduler.launcher.EasyOCREngine.create', + side_effect=_capture_easy_create, + ): + os.environ[gpu_key] = 'cuda' + launcher.create_ocr() + os.environ[gpu_key] = 'cpu' + launcher.create_ocr() + assert ocr_gpu_calls == [True, False], ( + 'AUTOWSGR_OCR_GPU_MODE 行为不兼容 (GUI probe step 2 fail) ' + f'actual={ocr_gpu_calls}' + ) + finally: + if previous_save is None: + os.environ.pop(save_key, None) + else: + os.environ[save_key] = previous_save + if previous_gpu is None: + os.environ.pop(gpu_key, None) + else: + os.environ[gpu_key] = previous_gpu diff --git a/testing/ops/test_manual_repair_termination.py b/testing/ops/test_manual_repair_termination.py new file mode 100644 index 00000000..ac2f9379 --- /dev/null +++ b/testing/ops/test_manual_repair_termination.py @@ -0,0 +1,30 @@ +"""测试手动维修会终止整个调度任务。""" + +from threading import Event +from types import SimpleNamespace + +from autowsgr.combat import CombatResult +from autowsgr.infra import ManualRepairRequiredError +from autowsgr.scheduler.scheduler import FightTask, TaskScheduler +from autowsgr.types import ConditionFlag + + +def test_manual_repair_does_not_consume_remaining_scheduler_rounds() -> None: + calls = 0 + + class Runner: + def run(self) -> CombatResult: + nonlocal calls + calls += 1 + raise ManualRepairRequiredError('需要进行手动修理') + + ctx = SimpleNamespace(stop_event=Event(), active_fight_tasks=0) + scheduler = TaskScheduler(ctx, expedition_interval=0) + task = FightTask(runner=Runner(), times=3) + + scheduler._run_task(task) + + assert calls == 1 + assert task.completed == 0 + assert len(task.results) == 1 + assert task.results[0].flag is ConditionFlag.ACTION_FAILED diff --git a/testing/ops/test_normal_fight_unit.py b/testing/ops/test_normal_fight_unit.py index fa19cfa8..2de6b0dc 100644 --- a/testing/ops/test_normal_fight_unit.py +++ b/testing/ops/test_normal_fight_unit.py @@ -24,7 +24,7 @@ ) from autowsgr.infra import ActionFailedError from autowsgr.ops.normal_fight import NormalFightRunner, _require_fleet_change -from autowsgr.types import ShipDamageState, ShipType +from autowsgr.types import ConditionFlag, PageName, ShipDamageState, ShipType from autowsgr.ui.battle.fleet_change._detect import FleetSnapshot @@ -453,3 +453,167 @@ def test_entrance_override(self): # override 回填 plan.entrance (alpha→'a', beta→'b') assert plan.entrance == 'b' assert runner._entrance == 'beta' + + +class TestDockFullDialogRoute: + """船坞满弹窗直达解装: 点弹窗「解装」直达解体标签, 不绕主菜单导航。 + + 正确 UI 路径 (实机): 战斗准备 → 点出征 → 船坞满弹窗 → 弹窗「解装」 + 按钮直达建造页 (返回无视 UI 栈直达主页) → 复用 destroy_ships 解装, + 结束在主页面, 下轮 run 重新导航进图出击。 + """ + + @staticmethod + def _make_runner(*, dock_full_destroy: bool = True) -> NormalFightRunner: + cfg = SimpleNamespace(dock_full_destroy=dock_full_destroy, destroy_ship_types=None) + ctx = SimpleNamespace(ctrl=None, config=cfg) + plan = CombatPlan.from_dict({'chapter': 2, 'map': 1}) + return NormalFightRunner(ctx, plan, resolve_fleet_selection(plan)) + + def test_destroy_success_marks_destroyed_keeps_flag(self, monkeypatch: pytest.MonkeyPatch): + """解装成功 → 置 dock_full_destroyed, flag 保持 DOCK_FULL (未开打不翻成功标志)。""" + import autowsgr.ops.destroy as destroy_module + + calls: list[bool] = [] + monkeypatch.setattr( + destroy_module, + 'destroy_ships_auto', + lambda _ctx, *, from_dialog: calls.append(from_dialog) or True, + ) + + runner = self._make_runner() + result = CombatResult(flag=ConditionFlag.DOCK_FULL) + runner._handle_dock_full(result) + + assert calls == [True] # 走弹窗直达路线 + assert result.flag is ConditionFlag.DOCK_FULL # 不翻 flag, 触发器不误计数 + assert result.dock_full_destroyed is True + + def test_destroy_exhausted_whitelist_keeps_flag(self, monkeypatch: pytest.MonkeyPatch): + """白名单覆盖全部舰种 → 无可解装对象 → 保持 DOCK_FULL。""" + import autowsgr.ops.destroy as destroy_module + + monkeypatch.setattr(destroy_module, 'destroy_ships_auto', lambda _ctx, **_k: False) + + runner = self._make_runner() + result = CombatResult(flag=ConditionFlag.DOCK_FULL) + runner._handle_dock_full(result) + + assert result.flag is ConditionFlag.DOCK_FULL + + def test_nav_error_falls_back_to_main(self, monkeypatch: pytest.MonkeyPatch): + """直达解装导航失败 → 回退主页面恢复已知态, 保持 DOCK_FULL。""" + import autowsgr.ops.destroy as destroy_module + from autowsgr.ui.utils import NavigationError + + def _raise(_ctx: object, **_k: object) -> None: + raise NavigationError('弹窗直达解装失败') + + monkeypatch.setattr(destroy_module, 'destroy_ships_auto', _raise) + + goto_calls: list[object] = [] + monkeypatch.setattr( + normal_fight_module, 'goto_page', lambda _ctx, target: goto_calls.append(target) + ) + + runner = self._make_runner() + result = CombatResult(flag=ConditionFlag.DOCK_FULL) + runner._handle_dock_full(result) + + assert result.flag is ConditionFlag.DOCK_FULL + assert goto_calls == [PageName.MAIN] + + +class TestRunForTimesDockFullBehavior: + """run_for_times (老旧 API, 不改): 解装成功后 flag 保持 DOCK_FULL → 提前 break。 + + 旧版解装成功翻 SUCCESS 会继续下一轮; 根治计数污染后 flag 不翻, + 循环保守停止, 由上层/用户决策再启动。 + """ + + def test_destroyed_dock_full_stops_loop(self, monkeypatch: pytest.MonkeyPatch): + runner = TestDockFullDialogRoute._make_runner() + + resolved = CombatResult(flag=ConditionFlag.DOCK_FULL, dock_full_destroyed=True) + calls: list[int] = [] + monkeypatch.setattr(runner, 'run', lambda **_k: calls.append(1) or resolved) + + results = runner.run_for_times(3) + + assert len(calls) == 1 # DOCK_FULL 即 break, 不再继续 + assert results == [resolved] + + +class TestSkipCheckLifecycle: + """skip_check 仅在成功完成一场战斗 (OPERATION_SUCCESS) 后置位。 + + 背景 (实机 2026-08-16 日志): 解装轮 (DOCK_FULL, 战斗未开打, 解装后停在 + 主页面) 也被无条件置 True, 下一轮带着"活动页浮层态在"的错误假设直接 + 出击 → 按钮匹配不到 → 回退固定坐标盲点误触节点卡片按出浮层 → 超时。 + 中途打断/失败一律恢复完整检查。 + """ + + @staticmethod + def _make_runner() -> NormalFightRunner: + cfg = SimpleNamespace(dock_full_destroy=False, destroy_ship_types=None) + ctx = SimpleNamespace( + ctrl=None, + config=cfg, + sync_before_combat=MagicMock(), + sync_after_combat=MagicMock(), + ) + plan = CombatPlan.from_dict({'chapter': 'H', 'map': 5}) + return NormalFightRunner(ctx, plan, resolve_fleet_selection(plan)) + + def _mock_flow( + self, + monkeypatch: pytest.MonkeyPatch, + runner: NormalFightRunner, + flag: ConditionFlag, + ) -> None: + """mock run() 流程各环节, _do_combat 返回指定 flag 的结果。""" + monkeypatch.setattr(runner, '_enter_fight', lambda: None) + monkeypatch.setattr(runner, '_prepare_for_battle', list) + monkeypatch.setattr(runner, '_do_combat', lambda _stats: CombatResult(flag=flag)) + monkeypatch.setattr(runner, '_handle_result', lambda _result: None) + monkeypatch.setattr(normal_fight_module.time, 'sleep', lambda _seconds: None) + + def test_first_run_starts_with_full_check(self): + """新 runner 从完整检查开始 (init 回归, 防语义漂移)。""" + assert self._make_runner()._skip_check is False + + def test_success_sets_skip_check(self, monkeypatch: pytest.MonkeyPatch): + """成功完成一场战斗 (战后回港必落关卡浮层态) → 下一轮可跳过检查。""" + runner = self._make_runner() + self._mock_flow(monkeypatch, runner, ConditionFlag.OPERATION_SUCCESS) + + runner.run() + + assert runner._skip_check is True + + def test_dock_full_resets_skip_check(self, monkeypatch: pytest.MonkeyPatch): + """解装轮 (DOCK_FULL, 战斗未开打) → 浮层态前提破坏, 恢复完整检查。""" + runner = self._make_runner() + runner._skip_check = True # 模拟上一轮成功 + self._mock_flow(monkeypatch, runner, ConditionFlag.DOCK_FULL) + + runner.run() + + assert runner._skip_check is False + + def test_mid_fight_error_resets_skip_check(self, monkeypatch: pytest.MonkeyPatch): + """中途异常 (导航超时等) → 下一轮恢复完整检查 (实机 log 场景回归)。""" + from autowsgr.ui.utils import NavigationError + + runner = self._make_runner() + runner._skip_check = True # 模拟上一轮成功 + + def _raise_nav_error() -> None: + raise NavigationError('等待超时: EVENT_MAP -> BATTLE_PREP') + + monkeypatch.setattr(runner, '_enter_fight', _raise_nav_error) + + with pytest.raises(NavigationError): + runner.run() + + assert runner._skip_check is False diff --git a/testing/ops/test_repair_unit.py b/testing/ops/test_repair_unit.py index 38b8c0e1..cd11d516 100644 --- a/testing/ops/test_repair_unit.py +++ b/testing/ops/test_repair_unit.py @@ -10,7 +10,10 @@ import types from typing import TYPE_CHECKING +from autowsgr.combat.history import CombatResult +from autowsgr.context import GameContext, Ship from autowsgr.context.bathroom import BathRoom +from autowsgr.types import ConditionFlag, ShipDamageState if TYPE_CHECKING: @@ -23,7 +26,7 @@ class _FakeBathPage: """BathPage 替身: 按预设序列返回 repair_longest 结果。""" - def __init__(self, results: list[int]) -> None: + def __init__(self, results: list[tuple[str, int]]) -> None: self._results = list(results) self.repair_longest_calls = 0 self.go_to_choose_repair_calls = 0 @@ -31,9 +34,15 @@ def __init__(self, results: list[int]) -> None: def go_to_choose_repair(self) -> None: self.go_to_choose_repair_calls += 1 - def repair_longest(self, blacklist: set[str] | None = None) -> int: # noqa: ARG002 # 签名匹配真实接口 (调用方按关键字传 blacklist=) + def repair_longest( + self, + blacklist: set[str] | None = None, # noqa: ARG002 # 签名匹配真实接口 (调用方按关键字传 blacklist=) + ) -> tuple[str, int]: self.repair_longest_calls += 1 - return self._results.pop(0) if self._results else -1 + return self._results.pop(0) if self._results else ('', -1) + + def click_repair_all(self) -> None: + return None class _FakeCtx: @@ -42,6 +51,28 @@ class _FakeCtx: def __init__(self, slot_count: int) -> None: self.bathroom = BathRoom(slot_count=slot_count) self.config = types.SimpleNamespace(bathroom_count=slot_count) + self.ship_registry: dict[str, Ship] = {} + + def get_ship(self, name: str) -> Ship: + if name not in self.ship_registry: + self.ship_registry[name] = Ship(name=name) + return self.ship_registry[name] + + def update_ship_damage(self, name: str, state: ShipDamageState) -> None: + self.get_ship(name).damage_state = state + + +def _context_with_ship() -> tuple[GameContext, Ship, Ship]: + ctx = GameContext( + ctrl=object(), + config=types.SimpleNamespace(bathroom_count=1), + ocr=object(), + ) + registry_ship = Ship(name='测试舰', damage_state=ShipDamageState.SEVERE) + fleet_ship = Ship(name='测试舰', damage_state=ShipDamageState.SEVERE) + ctx.ship_registry['测试舰'] = registry_ship + ctx.fleets[0].ships = [fleet_ship] + return ctx, registry_ship, fleet_ship def _patch_repair(monkeypatch: pytest.MonkeyPatch, fake_page: _FakeBathPage) -> None: @@ -56,11 +87,97 @@ def _patch_repair(monkeypatch: pytest.MonkeyPatch, fake_page: _FakeBathPage) -> # ── 循环填满 ── +def test_named_repair_syncs_fleet_damage_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + import autowsgr.ops.repair as mod + + class _NamedRepairPage: + def go_to_choose_repair(self) -> None: + return None + + def repair_ship(self, ship_name: str) -> int: + assert ship_name == '测试舰' + return 120 + + monkeypatch.setattr(mod, 'goto_page', lambda *_a, **_kw: None) + monkeypatch.setattr(mod, 'BathPage', lambda _ctx: _NamedRepairPage()) + + ctx, registry_ship, fleet_ship = _context_with_ship() + assert mod.repair_ship_by_name(ctx, '测试舰') == 120 + + assert registry_ship.damage_state is ShipDamageState.NORMAL + assert fleet_ship.damage_state is ShipDamageState.NORMAL + assert registry_ship.repair_end_time > 0 + assert fleet_ship.repair_end_time == 0 + + +def test_automatic_repair_syncs_known_ship_damage_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([('测试舰', 120)]) + _patch_repair(monkeypatch, fake) + ctx, registry_ship, fleet_ship = _context_with_ship() + + assert mod.repair_one_available(ctx) is True + + assert registry_ship.damage_state is ShipDamageState.NORMAL + assert fleet_ship.damage_state is ShipDamageState.NORMAL + assert registry_ship.repair_end_time > 0 + assert fleet_ship.repair_end_time == 0 + + +def test_batch_repair_does_not_guess_ship_state(monkeypatch: pytest.MonkeyPatch) -> None: + import autowsgr.ops.repair as mod + + fake = _FakeBathPage([]) + _patch_repair(monkeypatch, fake) + ctx, registry_ship, fleet_ship = _context_with_ship() + + assert mod.repair_in_bath(ctx) is None + + assert registry_ship.damage_state is ShipDamageState.SEVERE + assert fleet_ship.damage_state is ShipDamageState.SEVERE + assert registry_ship.repair_end_time == 0 + assert fleet_ship.repair_end_time == 0 + + +def test_sync_after_combat_uses_shared_damage_sync() -> None: + ctx, registry_ship, fleet_ship = _context_with_ship() + duplicate_fleet_ship = Ship(name='测试舰', damage_state=ShipDamageState.SEVERE) + ctx.fleets[1].ships = [duplicate_fleet_ship] + + ctx.sync_after_combat( + 1, + CombatResult( + flag=ConditionFlag.FIGHT_END, + ship_stats=[ShipDamageState.NORMAL], + ), + ) + + assert registry_ship.damage_state is ShipDamageState.NORMAL + assert fleet_ship.damage_state is ShipDamageState.NORMAL + assert duplicate_fleet_ship.damage_state is ShipDamageState.NORMAL + + +def test_sync_before_combat_uses_shared_damage_sync() -> None: + ctx, registry_ship, _ = _context_with_ship() + duplicate_fleet_ship = Ship(name='测试舰', damage_state=ShipDamageState.SEVERE) + ctx.fleets[1].ships = [duplicate_fleet_ship] + new_fleet_ship = Ship(name='测试舰', damage_state=ShipDamageState.NORMAL) + + ctx.sync_before_combat(1, [new_fleet_ship]) + + assert registry_ship.damage_state is ShipDamageState.NORMAL + assert new_fleet_ship.damage_state is ShipDamageState.NORMAL + assert duplicate_fleet_ship.damage_state is ShipDamageState.NORMAL + + def test_fills_all_free_slots_then_stops(monkeypatch: pytest.MonkeyPatch): """两空闲槽 → 连续派修两艘, 槽填满后停止 (不死循环)。""" import autowsgr.ops.repair as mod - fake = _FakeBathPage([100, 200]) # 两次都派单成功 + fake = _FakeBathPage([('测试舰1', 100), ('测试舰2', 200)]) # 两次都派单成功 _patch_repair(monkeypatch, fake) ctx = _FakeCtx(slot_count=2) @@ -75,7 +192,7 @@ def test_partial_fill_then_no_candidates(monkeypatch: pytest.MonkeyPatch): """修一艘后剩余无可修候选 (secs==-1): 派 1 艘后停止, 仍留 1 空闲槽。""" import autowsgr.ops.repair as mod - fake = _FakeBathPage([100, -1]) + fake = _FakeBathPage([('测试舰', 100), ('', -1)]) _patch_repair(monkeypatch, fake) ctx = _FakeCtx(slot_count=2) @@ -89,7 +206,7 @@ def test_no_candidates_first_try(monkeypatch: pytest.MonkeyPatch): """首次即无可修候选 (secs==-1): 不占用任何槽, 返回 False。""" import autowsgr.ops.repair as mod - fake = _FakeBathPage([-1]) + fake = _FakeBathPage([('', -1)]) _patch_repair(monkeypatch, fake) ctx = _FakeCtx(slot_count=2) @@ -103,7 +220,7 @@ def test_bath_full_marks_unknown(monkeypatch: pytest.MonkeyPatch): """浴场满 (secs==-2): mark_unknown 退避, 返回 False。""" import autowsgr.ops.repair as mod - fake = _FakeBathPage([-2]) + fake = _FakeBathPage([('', -2)]) _patch_repair(monkeypatch, fake) ctx = _FakeCtx(slot_count=2) diff --git a/testing/ops/test_scheduler_unit.py b/testing/ops/test_scheduler_unit.py index d475b7c0..165512f2 100644 --- a/testing/ops/test_scheduler_unit.py +++ b/testing/ops/test_scheduler_unit.py @@ -16,6 +16,7 @@ from autowsgr.combat import CombatResult from autowsgr.context import GameContext from autowsgr.scheduler.scheduler import BatchRunnerAdapter, FightTask, TaskScheduler +from autowsgr.scheduler.triggers import NormalFightPlan, NormalFightTrigger from autowsgr.types import ConditionFlag @@ -466,10 +467,11 @@ def __init__( def ensure_panel(self, _panel: object) -> None: return None - def get_loot_and_ship_count(self) -> types.SimpleNamespace: + def get_loot_and_ship_count(self, *, read_loot: bool = True) -> types.SimpleNamespace: if self._raises is not None: raise self._raises('OCR 不可用') - return types.SimpleNamespace(ship=self._ship, loot=self._loot) + loot = self._loot if read_loot else None + return types.SimpleNamespace(ship=self._ship, loot=loot) def _patch_ctx_sortie(monkeypatch: pytest.MonkeyPatch, page: _FakeSortiePage) -> None: @@ -484,7 +486,13 @@ def _patch_ctx_sortie(monkeypatch: pytest.MonkeyPatch, page: _FakeSortiePage) -> def _make_ctx() -> GameContext: - return GameContext(ctrl=object(), config=types.SimpleNamespace(), ocr=object()) # type: ignore[arg-type] + # 提供 daily_automation 配置 (stop_max_loot=True) 以使战利品 OCR 联动逻辑生效 + da = types.SimpleNamespace(stop_max_loot=True, stop_max_ship=True) + return GameContext( + ctrl=object(), + config=types.SimpleNamespace(daily_automation=da), + ocr=object(), + ) # type: ignore[arg-type] def test_ctx_sync_drop_counts_writes_both(monkeypatch: pytest.MonkeyPatch): @@ -524,6 +532,24 @@ def test_ctx_sync_drop_counts_raises_on_ocr_unavailable(monkeypatch: pytest.Monk assert ctx.dropped_loot_count == 0 +def test_ctx_sync_drop_counts_skips_loot_when_stop_max_loot_off(monkeypatch: pytest.MonkeyPatch): + """stop_max_loot 关闭 → 不识别战利品 (loot=None), 仅同步舰船数。 + + YAML 未开启战利品检查 (无战利品活动) 时, 战利品 OCR 区域无效, + 联动逻辑跳过该区域 OCR, 避免无效报警。 + """ + _patch_ctx_sortie(monkeypatch, _FakeSortiePage(ship=100, loot=50)) + ctx = _make_ctx() + # 关闭 stop_max_loot (模拟无战利品活动) + ctx.config.daily_automation.stop_max_loot = False # type: ignore[attr-defined] + ctx.dropped_ship_count = 0 + ctx.dropped_loot_count = 0 + + ctx.sync_daily_drop_counts() + assert ctx.dropped_ship_count == 100 # 舰船仍识别 + assert ctx.dropped_loot_count == 0 # 战利品未识别, 不覆盖 + + # ── NormalFightTrigger.disable ── @@ -553,3 +579,128 @@ def test_normal_fight_trigger_disable_survives_reset(): assert trigger._disabled is True assert trigger.should_fire(ctx) is None + + +# ── 战果条件计数 + DOCK_FULL 解装自愈 ── + + +def _fight_result(node: str, grade: str) -> CombatResult: + """构造一场成功战斗: flag=SUCCESS + 单节点战果。""" + from autowsgr.combat.history import CombatEvent, EventType + + result = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + result.history.add(CombatEvent(event_type=EventType.RESULT, node=node, result=grade)) + return result + + +def _make_normal_trigger( + conditions: tuple = (), + target: int = 3, +) -> tuple[NormalFightTrigger, NormalFightPlan]: + plan: NormalFightPlan = NormalFightPlan( + factory=lambda _c: object(), + name='x', + fleet_id=1, + target=target, + conditions=conditions, + ) + trigger: NormalFightTrigger = NormalFightTrigger(priority=100, name='常规战', plans=[plan]) + return trigger, plan + + +def test_normal_fight_trigger_counts_condition_met(): + """conditions 计划: 达标场次计数, 打满 target 后停止产出。""" + from autowsgr.combat import GradeCondition + + trigger, plan = _make_normal_trigger( + conditions=(GradeCondition(node='F', grade='S'),), + target=2, + ) + ctx = _ctx_with_da(None) + + for _ in range(2): + trigger.should_fire(ctx) + trigger._on_done(_fight_result('F', 'S')) + + assert plan.completed == 2 + assert trigger.should_fire(ctx) is None # 打满 + + +def test_normal_fight_trigger_condition_not_met_not_counted(): + """conditions 计划: 不达标场次 (评级不足) 不计数, 触发器继续产出。""" + from autowsgr.combat import GradeCondition + + trigger, plan = _make_normal_trigger(conditions=(GradeCondition(node='F', grade='S'),)) + ctx = _ctx_with_da(None) + + trigger.should_fire(ctx) + trigger._on_done(_fight_result('F', 'A')) # 评级不足 + + assert plan.completed == 0 + assert trigger._idle is True + assert trigger.should_fire(ctx) is not None # 未达标 → 下轮继续产出 + + +def test_normal_fight_trigger_dock_full_resolved_not_counted_retries(): + """计数污染根治的自愈路径: 解装轮未开打, 不计数; 触发器翻回后重新产出重打。""" + trigger, plan = _make_normal_trigger(target=3) + ctx = _ctx_with_da(None) + + trigger.should_fire(ctx) + trigger._on_done( + CombatResult(flag=ConditionFlag.DOCK_FULL, dock_full_destroyed=True), + ) + + assert plan.completed == 0 # 未开打, 不计数 (旧版翻 SUCCESS 会 +1) + assert trigger._idle is True + assert trigger.should_fire(ctx) is not None # 下轮重试 + assert trigger._current is plan # 有限未完成 plan 仍被选中 + + +def test_run_task_dock_full_resolved_round_does_not_consume_times( + monkeypatch: pytest.MonkeyPatch, +): + """scheduler: 解装轮不占 times — 解装 1 次 + 真打 3 次, 共 4 次 run 打满 3 轮。""" + ctx = _FakeCtx() + sched = TaskScheduler(ctx, expedition_interval=0) # type: ignore[arg-type] + monkeypatch.setattr(sched, '_maybe_collect_expedition', lambda: None) + + resolved = CombatResult(flag=ConditionFlag.DOCK_FULL, dock_full_destroyed=True) + ok = CombatResult(flag=ConditionFlag.OPERATION_SUCCESS) + seq = [resolved, ok, ok, ok] + calls: list[int] = [] + runner = _SingleRunner(ok) + + def run() -> CombatResult: + calls.append(1) + return seq[len(calls) - 1] + + runner.run = run # type: ignore[method-assign] + task = FightTask(runner=runner, times=3) + + sched._run_task(task) + + assert len(calls) == 4 + assert task.completed == 3 + assert task.results == [resolved, ok, ok, ok] + + +def test_register_normal_fight_passes_condition(monkeypatch: pytest.MonkeyPatch): + """daily_plan: CombatPlan.condition 镜像到 NormalFightPlan (触发器按条件计数)。""" + from autowsgr.combat import CombatPlan, GradeCondition + from autowsgr.infra.config import DailyAutomationConfig + from autowsgr.ops import normal_fight as nf_mod + from autowsgr.scheduler.daily_plan import _register_normal_fight + + plan = CombatPlan.from_dict({'node_args': {'F': {'grade': 'S'}}}) + monkeypatch.setattr(nf_mod, 'get_normal_fight_plan', lambda *_a, **_k: plan) + + sched = TaskScheduler(_FakeCtx(), expedition_interval=0) # type: ignore[arg-type] + cfg = DailyAutomationConfig( + auto_normal_fight=True, + normal_fight_tasks=[{'name': 'x', 'times': 3}], + ) + _register_normal_fight(sched, cfg) + + trigger = sched._triggers[-1] + assert trigger._plans[0].conditions == (GradeCondition('F', 'S'),) diff --git a/testing/server/test_system_routes.py b/testing/server/test_system_routes.py index 3251e6f1..e3fbb0bd 100644 --- a/testing/server/test_system_routes.py +++ b/testing/server/test_system_routes.py @@ -6,6 +6,7 @@ import sys import threading import types +from unittest.mock import MagicMock import pytest from fastapi import HTTPException @@ -68,20 +69,27 @@ def test_system_start_publishes_launched_context( """A successful start publishes the launched context exactly once.""" launched_context = object() launch_calls: list[str] = [] + startup_steps: list[str] = [] scheduler_module = types.ModuleType('autowsgr.scheduler') def launch(config_path: str) -> object: launch_calls.append(config_path) + startup_steps.append('launch') return launched_context + def register_stats_log_sink(_: asyncio.AbstractEventLoop) -> None: + startup_steps.append('sink') + scheduler_module.launch = launch # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, 'autowsgr.scheduler', scheduler_module) monkeypatch.setattr(server_main, '_ctx', None) + monkeypatch.setattr(server_main, 'register_stats_log_sink', register_stats_log_sink) response = asyncio.run(system.system_start(system.SystemStartRequest(config_path='test.yaml'))) assert response.success is True assert launch_calls == ['test.yaml'] + assert startup_steps == ['launch', 'sink'] assert server_main._ctx is launched_context @@ -157,13 +165,59 @@ def test_system_stop_releases_context_after_worker_finishes( ) -> None: """The global context is released only after worker termination is confirmed.""" manager = _RunningTaskManager(completed=True) - monkeypatch.setattr(server_main, '_ctx', object()) + ctrl = MagicMock() + monkeypatch.setattr(server_main, '_ctx', types.SimpleNamespace(ctrl=ctrl)) monkeypatch.setattr(system, 'task_manager', manager) response = asyncio.run(system.system_stop()) assert response.success is True assert server_main._ctx is None + ctrl.disconnect.assert_called_once_with() + + +def test_system_stop_keeps_context_when_disconnect_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed controller teardown leaves the context available for retry.""" + ctrl = MagicMock() + ctrl.disconnect.side_effect = RuntimeError('disconnect failed') + ctx = types.SimpleNamespace(ctrl=ctrl) + manager = _TerminalTaskManager(completed=True) + monkeypatch.setattr(server_main, '_ctx', ctx) + monkeypatch.setattr(system, 'task_manager', manager) + + response = asyncio.run(system.system_stop()) + + assert response.success is False + assert response.error == 'disconnect failed' + assert server_main._ctx is ctx + + token = device_operation_lease.acquire('test:disconnect-failure') + device_operation_lease.release(token) + + +def test_lifespan_stops_system_and_disconnects_controller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Application shutdown reuses the system stop lifecycle.""" + ctrl = MagicMock() + manager = _TerminalTaskManager(completed=True) + monkeypatch.setattr(server_main, '_ctx', types.SimpleNamespace(ctrl=ctrl)) + monkeypatch.setattr(server_main, 'lifecycle_lock', asyncio.Lock()) + monkeypatch.setattr(server_main, 'register_stats_log_sink', lambda _: None) + monkeypatch.setattr(server_main, 'remove_stats_log_sink', lambda: None) + monkeypatch.setattr(system, 'task_manager', manager) + + async def run_lifespan() -> None: + async with server_main.lifespan(server_main.app): + pass + + asyncio.run(run_lifespan()) + + assert manager.wait_called is True + assert server_main._ctx is None + ctrl.disconnect.assert_called_once_with() def test_system_stop_keeps_context_until_terminal_worker_exits( @@ -187,7 +241,7 @@ def test_task_start_cannot_reuse_context_being_stopped( ) -> None: """A new task cannot claim a context already owned by system shutdown.""" manager = _StoppingTaskManager() - ctx = type('Context', (), {'stop_event': None})() + ctx = types.SimpleNamespace(stop_event=None, ctrl=MagicMock()) started_contexts: list[object] = [] monkeypatch.setattr(server_main, '_ctx', ctx) monkeypatch.setattr(system, 'task_manager', manager) diff --git a/testing/server/test_task_manager.py b/testing/server/test_task_manager.py index 35b9135b..54587653 100644 --- a/testing/server/test_task_manager.py +++ b/testing/server/test_task_manager.py @@ -21,6 +21,96 @@ def _wait_until_finished(manager: TaskManager, timeout: float = 1.0) -> None: assert manager.is_running is False +def test_stop_request_wins_over_worker_finalization() -> None: + """A confirmed stop request cannot be overwritten by completion.""" + manager = TaskManager() + executor_ready = threading.Event() + release_executor = threading.Event() + worker_at_finalize = threading.Event() + release_finalizer = threading.Event() + worker_threads: list[threading.Thread] = [] + results = [{'round': 1, 'success': True}] + + class _FinalizationGate: + def __init__(self) -> None: + self._lock = threading.Lock() + + def __enter__(self) -> None: + if threading.current_thread() is worker_threads[0]: + worker_at_finalize.set() + assert release_finalizer.wait(timeout=1) + self._lock.acquire() + + def __exit__( + self, + _exc_type: object, + _exc_value: object, + _traceback: object, + ) -> None: + self._lock.release() + + def executor(_task: object) -> TaskOutcome: + worker_threads.append(threading.current_thread()) + executor_ready.set() + assert release_executor.wait(timeout=1) + return TaskOutcome.from_results(results) + + manager.start_task(task_type='normal_fight', total_rounds=1, executor=executor) + try: + assert executor_ready.wait(timeout=1) + manager._lock = _FinalizationGate() + release_executor.set() + assert worker_at_finalize.wait(timeout=1) + assert manager.stop_task() is True + finally: + release_executor.set() + release_finalizer.set() + + assert manager.wait_for_completion(timeout=1) + assert manager.current_task is not None + assert manager.current_task.status is TaskStatus.STOPPED + assert manager.current_task.results == results + assert manager.get_status()['status'] == TaskStatus.STOPPED.value + + +def test_closed_loop_drops_worker_notifications_without_task_failure() -> None: + """Shutdown cannot turn an already stopped task into a worker exception.""" + manager = TaskManager() + loop = asyncio.new_event_loop() + manager.set_loop(loop) + worker_ready = threading.Event() + release_worker = threading.Event() + thread_errors: list[object] = [] + original_excepthook = threading.excepthook + + def capture_thread_error(args: object) -> None: + thread_errors.append(args.exc_value) + + def executor(_task: object) -> TaskOutcome: + worker_ready.set() + assert release_worker.wait(timeout=1) + manager.update_progress(current_round=1) + return TaskOutcome.from_results([{'round': 1, 'success': True}]) + + threading.excepthook = capture_thread_error + try: + manager.start_task(task_type='normal_fight', total_rounds=1, executor=executor) + assert worker_ready.wait(timeout=1) + assert manager.stop_task() is True + loop.close() + release_worker.set() + assert manager.wait_for_completion(timeout=1) + finally: + release_worker.set() + if not loop.is_closed(): + loop.close() + threading.excepthook = original_excepthook + + assert thread_errors == [] + assert manager.current_task is not None + assert manager.current_task.status is TaskStatus.STOPPED + + def test_failed_round_marks_task_failed_and_preserves_details() -> None: """A handled round failure must not be broadcast as task success.""" manager = TaskManager() diff --git a/testing/server/test_task_routes.py b/testing/server/test_task_routes.py index 9108e71b..91a0705b 100644 --- a/testing/server/test_task_routes.py +++ b/testing/server/test_task_routes.py @@ -12,7 +12,7 @@ import autowsgr.ops.normal_fight as normal_fight_module from autowsgr import ops -from autowsgr.combat import CombatResult +from autowsgr.combat import CombatPlan, CombatResult from autowsgr.combat.fleet import FleetSelectionSource, ResolvedFleetSelection from autowsgr.server import main as server_main from autowsgr.server.device_lease import DeviceOperationBusyError @@ -25,6 +25,7 @@ EventFightRequest, ExerciseRequest, FleetRuleRequest, + NodeDecisionRequest, NormalFightRequest, RoundResult, TaskStatusResponse, @@ -85,6 +86,22 @@ def start_task( return 'task_test' +def test_task_stop_reports_idle_when_atomic_stop_loses_race( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The stop route reports no active task when finalization wins the race.""" + + def stop_task() -> bool: + return False + + monkeypatch.setattr(task, 'task_manager', SimpleNamespace(stop_task=stop_task)) + + response = asyncio.run(task.task_stop()) + + assert response.success is True + assert response.data is None + + def test_task_start_rejects_concurrent_task(monkeypatch: pytest.MonkeyPatch) -> None: """Task admission rejects a second running task under the lifecycle lock.""" monkeypatch.setattr(task, 'task_manager', _TaskManager(is_running=True)) @@ -106,6 +123,45 @@ def test_task_start_requires_system_context(monkeypatch: pytest.MonkeyPatch) -> assert exc_info.value.status_code == 503 +def test_campaign_route_preserves_out_of_times_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Campaign terminal reason reaches the task outcome consumed by the GUI.""" + manager = _ExecutingTaskManager() + + class CampaignRunnerStub: + def __init__( + self, + _ctx: object, + *, + campaign_name: str, + times: int, + ) -> None: + assert campaign_name == '困难航母' + assert times == 1 + + @staticmethod + def run() -> list[CombatResult]: + return [CombatResult(flag=ConditionFlag.BATTLE_TIMES_EXCEED)] + + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, 'CampaignRunner', CampaignRunnerStub) + + response = asyncio.run( + task._start_campaign( + object(), + CampaignRequest(campaign_name='困难航母', times=1), + ) + ) + + assert response.success is True + assert manager.outcome is not None + assert manager.outcome.success is False + assert manager.outcome.results[0]['success'] is False + assert manager.outcome.results[0]['result'] == ConditionFlag.BATTLE_TIMES_EXCEED.value + assert manager.results == manager.outcome.results + + def test_task_start_reports_device_conflict(monkeypatch: pytest.MonkeyPatch) -> None: """Task lease conflicts are returned synchronously as HTTP 409.""" manager = _TaskManager() @@ -171,7 +227,7 @@ class ErrorController: def __init__(self, _ctx: object, _config: object) -> None: pass - def run(self) -> DecisiveResult: + def run(self, *, full_recovery_check: bool = False) -> DecisiveResult: return DecisiveResult.ERROR manager = _ExecutingTaskManager() @@ -193,6 +249,91 @@ def run(self) -> DecisiveResult: ] +def test_decisive_error_retries_after_sl_recovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A transient decisive ERROR is recovered before the task fails.""" + from autowsgr.ops import DecisiveResult + + calls = 0 + recoveries: list[int] = [] + modes: list[bool] = [] + + class RetryController: + def __init__(self, _ctx: object, _config: object) -> None: + pass + + def run(self, *, full_recovery_check: bool = False) -> DecisiveResult: + nonlocal calls + calls += 1 + modes.append(full_recovery_check) + return DecisiveResult.ERROR if calls < 3 else DecisiveResult.CHAPTER_CLEAR + + manager = _ExecutingTaskManager() + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, 'DecisiveController', RetryController) + monkeypatch.setattr( + task, + '_recover_decisive_after_error', + lambda _ctx, attempt: recoveries.append(attempt), + ) + + asyncio.run(task._start_decisive(object(), DecisiveRequest())) + + assert calls == 3 + assert recoveries == [1, 2] + assert modes == [False, True, True] + assert manager.outcome is not None + assert manager.outcome.success is True + assert manager.outcome.results == [{'round': 1, 'success': True, 'result': 'chapter_clear'}] + + +def test_decisive_error_fails_after_retry_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Persistent decisive ERROR only fails after the bounded recovery budget.""" + from autowsgr.ops import DecisiveResult + + calls = 0 + recoveries: list[int] = [] + modes: list[bool] = [] + + class ErrorController: + def __init__(self, _ctx: object, _config: object) -> None: + pass + + def run(self, *, full_recovery_check: bool = False) -> DecisiveResult: + nonlocal calls + calls += 1 + modes.append(full_recovery_check) + return DecisiveResult.ERROR + + manager = _ExecutingTaskManager() + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, 'DecisiveController', ErrorController) + monkeypatch.setattr( + task, + '_recover_decisive_after_error', + lambda _ctx, attempt: recoveries.append(attempt), + ) + + asyncio.run(task._start_decisive(object(), DecisiveRequest())) + + assert calls == task._DECISIVE_MAX_ATTEMPTS + assert recoveries == [1, 2, 3] + assert modes == [False, True, True] + assert manager.outcome is not None + assert manager.outcome.success is False + assert manager.outcome.results == [ + { + 'round': 1, + 'success': False, + 'result': 'error', + 'error': '决战异常退出', + } + ] + + def test_decisive_leave_result_remains_successful( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -204,7 +345,7 @@ class LeaveController: def __init__(self, _ctx: object, _config: object) -> None: pass - def run(self) -> DecisiveResult: + def run(self, *, full_recovery_check: bool = False) -> DecisiveResult: return DecisiveResult.LEAVE manager = _ExecutingTaskManager() @@ -434,6 +575,75 @@ def run_event_fight( assert captured[0].fleet_id == 5 +def test_normal_route_applies_explicit_node_overrides_to_yaml_plan( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """GUI 随请求发送的节点阵型应覆盖入队时生成的 YAML 快照。""" + manager = _ExecutingTaskManager() + captured: list[CombatPlan] = [] + + def run_normal_fight( + _ctx: object, + plan: CombatPlan, + *, + times: int, + fleet_selection: ResolvedFleetSelection, + ) -> list[CombatResult]: + assert times == 1 + assert fleet_selection.fleet_id == 1 + captured.append(plan) + return [CombatResult(flag=ConditionFlag.OPERATION_SUCCESS)] + + yaml_path = tmp_path / 'node-formation.yaml' + yaml_path.write_text( + '\n'.join( + [ + 'chapter: 7', + 'map: 4', + 'selected_nodes: [A, B]', + 'node_defaults:', + ' formation: 2', + 'node_args:', + ' B:', + ' formation: 3', + ' sl_when_detour_fails: false', + '', + ], + ), + encoding='utf-8', + ) + request = NormalFightRequest( + plan_id=str(yaml_path), + plan=CombatPlanRequest( + node_defaults=NodeDecisionRequest(formation=2), + node_args={ + 'B': NodeDecisionRequest( + formation=4, + night=True, + long_missile_support=True, + proceed=False, + SL_when_detour_fails=True, + ) + }, + ), + ) + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, 'run_normal_fight', run_normal_fight) + + response = asyncio.run(task._start_normal_fight(object(), request)) + + assert response.success is True + assert len(captured) == 1 + decision = captured[0].get_node_decision('B') + assert decision.formation.value == 4 + assert decision.night is True + assert decision.long_missile_support is True + assert decision.proceed is False + assert decision.SL_when_detour_fails is True + assert not hasattr(decision, 'sl_when_detour_fails') + + def test_normal_route_enters_real_runner_with_resolved_selection( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/testing/server/test_ws_log_sink.py b/testing/server/test_ws_log_sink.py new file mode 100644 index 00000000..20a36439 --- /dev/null +++ b/testing/server/test_ws_log_sink.py @@ -0,0 +1,154 @@ +"""Regression tests for GUI stats log forwarding.""" + +from __future__ import annotations + +import asyncio +import json +import threading +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +from fastapi import WebSocketDisconnect + +from autowsgr.server import main as server_main +from autowsgr.server.ws_manager import WebSocketManager + + +if TYPE_CHECKING: + from collections.abc import Callable + + import pytest + + +class _FakeLoguru: + def __init__(self) -> None: + self.sinks: dict[int, Callable[[Any], None]] = {} + self.removed: list[int] = [] + self._next_id = 1 + + def add(self, sink: Callable[[Any], None], **_: object) -> int: + sink_id = self._next_id + self._next_id += 1 + self.sinks[sink_id] = sink + return sink_id + + def remove(self, sink_id: int) -> None: + self.removed.append(sink_id) + if sink_id not in self.sinks: + raise ValueError('sink has already been removed') + del self.sinks[sink_id] + + +class _FakeWebSocket: + def __init__(self, delivered: asyncio.Event) -> None: + self.delivered = delivered + self.messages: list[dict[str, Any]] = [] + + async def accept(self) -> None: + return None + + async def send_text(self, data: str) -> None: + self.messages.append(json.loads(data)) + self.delivered.set() + + +class _DisconnectingWebSocket: + async def receive_text(self) -> str: + raise WebSocketDisconnect(code=1000) + + +class _StreamSpy: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + async def connect(self, _websocket: object, stream: str) -> None: + self.calls.append(('connect', stream)) + + async def disconnect(self, _websocket: object, stream: str) -> None: + self.calls.append(('disconnect', stream)) + + +def test_ship_drop_sink_reregisters_and_dispatches_from_worker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A logger reset must not prevent worker-thread ship drops reaching the GUI.""" + + async def exercise() -> None: + manager = WebSocketManager() + delivered = asyncio.Event() + websocket = _FakeWebSocket(delivered) + await manager.connect(websocket, 'logs') # type: ignore[arg-type] + + loguru = _FakeLoguru() + monkeypatch.setattr(server_main, '_loguru_logger', loguru) + monkeypatch.setattr(server_main, 'ws_manager', manager) + monkeypatch.setattr(server_main, '_stats_sink_id', 7) + + server_main.register_stats_log_sink(asyncio.get_running_loop()) + sink = loguru.sinks[1] + worker = threading.Thread( + target=sink, + args=( + SimpleNamespace( + record={ + 'message': '[Combat] 获得舰船: 测试舰', + 'level': 'INFO', + 'extra': {'ch': 'combat.handlers'}, + } + ), + ), + ) + worker.start() + try: + await asyncio.wait_for(delivered.wait(), timeout=1) + finally: + worker.join(timeout=1) + server_main.remove_stats_log_sink() + + assert not worker.is_alive() + assert loguru.removed == [7, 1] + assert websocket.messages[0]['type'] == 'log' + assert websocket.messages[0]['message'] == '[Combat] 获得舰船: 测试舰' + assert websocket.messages[0]['channel'] == 'combat.handlers' + + asyncio.run(exercise()) + + +def test_messages_stay_within_their_stream() -> None: + async def exercise() -> None: + manager = WebSocketManager() + log_websocket = _FakeWebSocket(asyncio.Event()) + task_websocket = _FakeWebSocket(asyncio.Event()) + await manager.connect(log_websocket, 'logs') # type: ignore[arg-type] + await manager.connect(task_websocket, 'task') # type: ignore[arg-type] + + await manager.send_log('INFO', '[Combat] 获得舰船: 测试舰') + await manager.send_task_update('task_1', 'running') + await manager.send_task_completed('task_1', True) + + assert [message['type'] for message in log_websocket.messages] == ['log'] + assert [message['type'] for message in task_websocket.messages] == [ + 'task_update', + 'task_completed', + ] + + asyncio.run(exercise()) + + +def test_websocket_endpoints_use_matching_streams(monkeypatch: pytest.MonkeyPatch) -> None: + async def exercise() -> None: + spy = _StreamSpy() + websocket = _DisconnectingWebSocket() + monkeypatch.setattr(server_main, 'ws_manager', spy) + + await server_main.ws_logs(websocket) # type: ignore[arg-type] + await server_main.ws_task(websocket) # type: ignore[arg-type] + + assert spy.calls == [ + ('connect', 'logs'), + ('disconnect', 'logs'), + ('connect', 'task'), + ('disconnect', 'task'), + ] + + asyncio.run(exercise()) diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index adc65d84..f11500c8 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -16,7 +16,11 @@ FleetRuleRequest, NodeDecisionRequest, ) -from autowsgr.server.serializers import build_combat_plan, build_fleet_selection +from autowsgr.server.serializers import ( + apply_combat_plan_overrides, + build_combat_plan, + build_fleet_selection, +) from autowsgr.types import ShipType @@ -424,6 +428,15 @@ def test_node_decision_request_keeps_yaml_supported_fields(): assert decision.formation_when_spot_enemy_fails == 3 +@pytest.mark.parametrize('condition', ['ap>=1', 'ap > = 1']) +def test_node_decision_request_accepts_legacy_rule_spelling(condition: str): + decision = NodeDecisionRequest.model_validate( + {'enemy_rules': [[condition, 4]]}, + ) + + assert decision.enemy_rules == [(condition, 4)] + + def test_api_combat_plan_parses_event_entrance_and_node_fields(): request = CombatPlanRequest( mode='event', @@ -476,3 +489,58 @@ def test_api_node_args_inherit_defaults_and_keep_explicit_overrides(): assert decision.formation.value == 3 assert decision.night is True assert decision.detour is False + + +def test_yaml_nodes_reinherit_api_defaults_and_keep_explicit_overrides(): + plan = CombatPlan.from_dict( + { + 'selected_nodes': ['A', 'B', 'C'], + 'node_defaults': {'formation': 2, 'night': False}, + 'node_args': { + 'B': {'formation': 3}, + 'C': {'night': False}, + }, + }, + ) + + apply_combat_plan_overrides( + plan, + CombatPlanRequest( + node_defaults=NodeDecisionRequest( + formation=4, + night=True, + ), + ), + ) + + assert plan.nodes['A'].formation.value == 4 + assert plan.nodes['A'].night is True + assert plan.nodes['B'].formation.value == 3 + assert plan.nodes['B'].night is True + assert plan.nodes['C'].formation.value == 4 + assert plan.nodes['C'].night is False + + +def test_yaml_legacy_detour_sl_field_survives_api_default_override(): + plan = CombatPlan.from_dict( + { + 'selected_nodes': ['A'], + 'node_args': { + 'A': { + 'detour': True, + 'sl_when_detour_fails': False, + }, + }, + }, + ) + + apply_combat_plan_overrides( + plan, + CombatPlanRequest( + node_defaults=NodeDecisionRequest(formation=4), + ), + ) + + assert plan.nodes['A'].detour is True + assert plan.nodes['A'].SL_when_detour_fails is False + assert not hasattr(plan.nodes['A'], 'sl_when_detour_fails') diff --git a/testing/tools/test_debug_tool.py b/testing/tools/test_debug_tool.py new file mode 100644 index 00000000..43ddd5be --- /dev/null +++ b/testing/tools/test_debug_tool.py @@ -0,0 +1,47 @@ +"""Tests for the unified debug command helpers.""" + +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING + +import cv2 +import numpy as np +import pytest + +from tools.debug_toolkit.function.roi import _parse_roi +from tools.debug_toolkit.function.roi import run as run_roi + + +if TYPE_CHECKING: + from pathlib import Path + + +def test_parse_roi_rejects_invalid_order() -> None: + with pytest.raises(argparse.ArgumentTypeError, match='upper-left'): + _parse_roi('0.5,0.5,0.4,0.6') + + +def test_crop_roi_writes_requested_region(tmp_path: Path) -> None: + source = np.zeros((100, 200, 3), dtype=np.uint8) + source[20:60, 50:150] = (10, 20, 30) + source_path = tmp_path / 'screen.png' + cv2.imwrite(str(source_path), source) + + assert ( + run_roi( + type( + 'Args', + (), + { + 'image': source_path, + 'roi': (0.25, 0.2, 0.75, 0.6), + 'output_root': tmp_path / 'result', + }, + )() + ) + == 0 + ) + result = cv2.imread(str(tmp_path / 'result' / 'screen' / '1x' / 'screen_roi_1x.png')) + assert result is not None + assert result.shape == (40, 100, 3) diff --git a/testing/tools/test_ocr_crop_tool.py b/testing/tools/test_ocr_crop_tool.py new file mode 100644 index 00000000..6409ad34 --- /dev/null +++ b/testing/tools/test_ocr_crop_tool.py @@ -0,0 +1,149 @@ +"""OCR 截图裁切工具测试。""" + +from __future__ import annotations + +import subprocess +from datetime import UTC, datetime +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from tools import ocr_crop_tool + + +def _team_screen(*, occupied_slots: tuple[int, ...] = ()) -> np.ndarray: + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + no_ship_bgr = np.array((112, 87, 43), dtype=np.uint8) + normal_bgr = np.array((118, 168, 75), dtype=np.uint8) + for slot, (x_ratio, y_ratio) in ocr_crop_tool.TEAM_BLOOD_PROBES.items(): + x = round(x_ratio * screen.shape[1]) + y = round(y_ratio * screen.shape[0]) + screen[y, x] = normal_bgr if slot in occupied_slots else no_ship_bgr + return screen + + +def test_crop_team_filters_empty_slots_and_saves_four_scales(tmp_path: Path): + screen = _team_screen(occupied_slots=(1,)) + + valid_slots, saved_images = ocr_crop_tool.crop_team(screen, tmp_path) + + assert valid_slots == 1 + assert saved_images == 12 + assert (tmp_path / 'team/name/Team-slot-1-name-1X.png').is_file() + assert (tmp_path / 'team/level/Team-slot-1-level-4X.png').is_file() + assert (tmp_path / 'team/type/Team-slot-1-type-3X.png').is_file() + assert not (tmp_path / 'team/name/Team-slot-2-name-1X.png').exists() + + +def test_team_scaled_crop_uses_requested_size(tmp_path: Path): + screen = _team_screen(occupied_slots=(1,)) + ocr_crop_tool.crop_team(screen, tmp_path) + + original = cv2.imread(str(tmp_path / 'team/name/Team-slot-1-name-1X.png')) + enlarged = cv2.imread(str(tmp_path / 'team/name/Team-slot-1-name-4X.png')) + + assert original is not None + assert enlarged is not None + expected_shape = (original.shape[0] * 4, original.shape[1] * 4) + assert abs(enlarged.shape[0] - expected_shape[0]) <= 1 + assert abs(enlarged.shape[1] - expected_shape[1]) <= 1 + + +def test_crop_pool_uses_dll_row_and_filters_empty_cards(tmp_path: Path): + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + screen[350:360, 110:135] = 255 + + valid_cards, saved_images = ocr_crop_tool.crop_pool( + screen, + tmp_path, + locator=lambda _image: [(346, 372)], + ) + + assert valid_cards == 1 + assert saved_images == 12 + assert (tmp_path / 'pool/name/Pool-slot-1-name-1X.png').is_file() + assert (tmp_path / 'pool/level/Pool-slot-1-level-2X.png').is_file() + assert (tmp_path / 'pool/type/Pool-slot-1-type-4X.png').is_file() + assert not (tmp_path / 'pool/name/Pool-slot-2-name-1X.png').exists() + + +def test_crop_pool_preserves_physical_slot_number(tmp_path: Path): + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + screen[350:360, 250:275] = 255 + + valid_cards, _saved_images = ocr_crop_tool.crop_pool( + screen, + tmp_path, + locator=lambda _image: [(346, 372)], + ) + + assert valid_cards == 1 + assert (tmp_path / 'pool/name/Pool-slot-2-name-1X.png').is_file() + assert not (tmp_path / 'pool/name/Pool-slot-1-name-1X.png').exists() + + +def test_crop_pool_rejects_page_without_dll_rows(tmp_path: Path): + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + with pytest.raises(ocr_crop_tool.CropToolError, match='DLL 未定位到船池名称条'): + ocr_crop_tool.crop_pool(screen, tmp_path, locator=lambda _image: []) + + +def test_prepare_summary_target_appends_sequence_and_builds_tree(tmp_path: Path): + now = datetime(2026, 8, 8, 12, 34, 56, tzinfo=UTC) + + first = ocr_crop_tool.prepare_capture_target(tmp_path, 'summary', 'team', now) + (first.root / f'adb-team{first.suffix}.png').touch() + second = ocr_crop_tool.prepare_capture_target(tmp_path, 'summary', 'team', now) + + assert first.root == tmp_path / '20260808' + assert first.suffix == '-001' + assert second.suffix == '-002' + assert (first.root / 'team/name').is_dir() + assert (first.root / 'pool/level').is_dir() + assert (first.root / 'pool/type').is_dir() + + +def test_prepare_timestamp_target_uses_second_precision(tmp_path: Path): + now = datetime(2026, 8, 8, 12, 34, 56, tzinfo=UTC) + + first = ocr_crop_tool.prepare_capture_target(tmp_path, 'timestamp', 'pool', now) + (first.root / f'adb-pool{first.suffix}.png').touch() + second = ocr_crop_tool.prepare_capture_target(tmp_path, 'timestamp', 'pool', now) + + assert first.root == tmp_path / '20260808-123456' + assert first.suffix == '' + assert second.suffix == '-002' + + +def test_adb_command_accepts_custom_serial(monkeypatch: pytest.MonkeyPatch): + calls: list[tuple[Path, str]] = [] + monkeypatch.setattr(ocr_crop_tool, '_resolve_adb_path', lambda _explicit: Path('adb.exe')) + monkeypatch.setattr( + ocr_crop_tool, + 'connect_device', + lambda adb_path, serial: calls.append((adb_path, serial)), + ) + + result = ocr_crop_tool.main(['adb', '127.0.0.1:5555']) + + assert result == 0 + assert calls == [(Path('adb.exe'), '127.0.0.1:5555')] + + +def test_connect_device_checks_state_and_saves_serial(monkeypatch: pytest.MonkeyPatch): + responses = iter( + [ + subprocess.CompletedProcess([], 0, stdout='already connected', stderr=''), + subprocess.CompletedProcess([], 0, stdout='device\n', stderr=''), + ], + ) + saved: list[str] = [] + monkeypatch.setattr(ocr_crop_tool, '_run_adb', lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(ocr_crop_tool, '_save_serial', saved.append) + + ocr_crop_tool.connect_device(Path('adb.exe'), '127.0.0.1:16384') + + assert saved == ['127.0.0.1:16384'] diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index a29d3a96..8f10e2bd 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -21,7 +21,6 @@ from autowsgr.infra import DecisiveConfig from autowsgr.server.schemas import FleetRuleRequest from autowsgr.types import ShipDamageState, ShipType -from autowsgr.ui.battle.base import PAGE_SIGNATURE from autowsgr.ui.battle.constants import ( AUTO_SUPPLY_PROBE, CLICK_AUTO_SUPPLY, @@ -156,9 +155,13 @@ def _make_screen( ax, ay = AUTO_SUPPLY_PROBE _set_pixel(screen, ax, ay, _AUTO_ON if auto_supply else _AUTO_OFF) - # 页面签名像素(使 is_current_page 返回 True) - for rule in PAGE_SIGNATURE.rules: - _set_pixel(screen, rule.x, rule.y, rule.color.as_rgb_tuple()) + # 出征准备页面模板 (使 is_current_page 模板匹配命中)。 + # 贴到右上角空白区, 避开舰队/面板/补给探测点 (均在中下及左侧)。 + from autowsgr.image_resources._lazy import load_template + + tmpl = load_template('page/fight_prepare_540p.png') + th, tw = tmpl.image.shape[:2] + screen[0:th, _W - tw : _W] = tmpl.image return screen @@ -301,35 +304,42 @@ def test_level_parser_uses_shared_rules( class TestIsCurrentPage: + """is_current_page 用页面模板匹配, 不校验舰队/面板状态。 + + 合成截图 _make_screen 已嵌入出征准备模板 (贴右上角), 故各状态变化下 + is_current_page 仍命中; 状态查询 (get_selected_fleet / get_active_panel) + 由专门的 Test 类覆盖。 + """ + def test_default_state_detected(self): screen = _make_screen() - assert BattlePreparationPage.is_current_page(screen) is True + assert BattlePreparationPage.is_current_page(screen).matched def test_fleet_2_selected(self): screen = _make_screen(selected_fleet=2) - assert BattlePreparationPage.is_current_page(screen) is True + assert BattlePreparationPage.is_current_page(screen).matched def test_fleet_4_quick_repair(self): screen = _make_screen(selected_fleet=4, active_panel=Panel.QUICK_REPAIR) - assert BattlePreparationPage.is_current_page(screen) is True + assert BattlePreparationPage.is_current_page(screen).matched def test_blank_screen_not_detected(self): - # 缺少签名的屏幕不应被识别为出征准备页 + # 缺少模板的屏幕不应被识别为出征准备页 screen = np.zeros((_H, _W, 3), dtype=np.uint8) - assert BattlePreparationPage.is_current_page(screen) is False + assert not BattlePreparationPage.is_current_page(screen).matched def test_two_fleets_selected_still_detected(self): - """is_current_page 仅验证页面签名,不校验状态合法性。""" + """is_current_page 仅验证页面模板,不校验状态合法性。""" screen = _make_screen(selected_fleet=1) _set_pixel(screen, *FLEET_PROBE[2], _FLEET_SELECTED) - assert BattlePreparationPage.is_current_page(screen) is True + assert BattlePreparationPage.is_current_page(screen).matched def test_no_panel_selected_still_detected(self): - """is_current_page 仅验证页面签名,不校验面板状态。""" + """is_current_page 仅验证页面模板,不校验面板状态。""" screen = _make_screen() - # 把唯一选中的面板清掉,签名仍在 + # 把唯一选中的面板清掉,模板仍在 _set_pixel(screen, *PANEL_PROBE[Panel.STATS], _PANEL_UNSELECTED) - assert BattlePreparationPage.is_current_page(screen) is True + assert BattlePreparationPage.is_current_page(screen).matched # ───────────────────────────────────────────── @@ -390,22 +400,49 @@ def page(self) -> tuple[BattlePreparationPage, MagicMock]: ctrl = MagicMock(spec=AndroidController) return BattlePreparationPage(_make_ctx(ctrl)), ctrl - def test_go_back(self, page: tuple[BattlePreparationPage, MagicMock]): + def test_go_back( + self, + page: tuple[BattlePreparationPage, MagicMock], + monkeypatch: pytest.MonkeyPatch, + ) -> None: pg, ctrl = page - # go_back 调用 click_and_wait_leave_page,会截图验证是否离开当前页 - # mock screenshot 先返回当前页,再返回地图页 - from autowsgr.ui.battle.base import PAGE_SIGNATURE as BATTLE_PREP_SIG - - # 第一次:BATTLE_PREP(带签名) - screen_prep = np.zeros((540, 960, 3), dtype=np.uint8) - for rule in BATTLE_PREP_SIG.rules: - _set_pixel(screen_prep, rule.x, rule.y, rule.color.as_rgb_tuple()) - # 第二次:空白页(无签名) - screen_blank = np.zeros((540, 960, 3), dtype=np.uint8) - ctrl.screenshot.side_effect = [screen_prep, screen_blank] + # go_back 用到达验证 (click_and_wait_for_page): 点击后需识别为 MAP 才算成功。 + # MapPage 识别走 tabbed 模板匹配, 构造假帧太重, 直接 mock checker 命中。 + from autowsgr.ui.map.page import MapPage + from autowsgr.ui.page import PageMatch - with patch( - 'autowsgr.ui.utils.navigation.time.sleep', + monkeypatch.setattr( + MapPage, + 'is_current_page', + staticmethod(lambda _s: PageMatch(name='map', matched=True, score=0.9)), + ) + ctrl.screenshot.return_value = np.zeros((540, 960, 3), dtype=np.uint8) + + with patch('autowsgr.ui.utils.navigation.time.sleep'): + pg.go_back() + ctrl.click.assert_called_with(*CLICK_BACK) + + def test_go_back_raises_when_map_not_reached( + self, + page: tuple[BattlePreparationPage, MagicMock], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """到达验证: 点击后画面仍在准备页 (MAP 不命中) → NavigationError, 不再假成功。""" + pg, ctrl = page + from autowsgr.ui.map.page import MapPage + from autowsgr.ui.page import PageMatch + from autowsgr.ui.utils import NavigationError + + monkeypatch.setattr( + MapPage, + 'is_current_page', + staticmethod(lambda _s: PageMatch(name='map', matched=False, score=0.0)), + ) + ctrl.screenshot.return_value = np.zeros((540, 960, 3), dtype=np.uint8) + + with ( + patch('autowsgr.ui.utils.navigation.time.sleep'), + pytest.raises(NavigationError), ): pg.go_back() ctrl.click.assert_called_with(*CLICK_BACK) @@ -1372,94 +1409,6 @@ def test_decisive_uses_new_flow_when_enabled(self): new_change.assert_called_once_with(None, exact_fleet_rules(['A'])) - -class TestFleetSelection: - @pytest.mark.parametrize( - 'aliases', - [ - {'别名甲': '85工程', '别名乙': '85工程'}, - {'别名乙': '85工程', '别名甲': '85工程'}, - ], - ) - def test_try_select_option_retries_all_aliases_in_stable_order( - self, - aliases: dict[str, str], - ): - page = BattlePreparationPage( - _make_ctx(MagicMock(spec=AndroidController), MagicMock()), - ) - set_user_ship_name_aliases(aliases) - option = ShipSelector( - name='85工程', - ship_types=(ShipType.CV,), - min_level=100, - ) - first_page = MagicMock() - first_page.change_single_ship.return_value = None - second_page = MagicMock() - second_page.change_single_ship.return_value = '85工程' - - with ( - patch.object( - page, - '_open_choose_page', - side_effect=[first_page, second_page], - ) as open_page, - patch.object(page, '_cancel_choose_page') as cancel_page, - ): - selected = page._try_select_option(2, option) - - attempted = [ - first_page.change_single_ship.call_args.args[0], - second_page.change_single_ship.call_args.args[0], - ] - assert selected == _ShipSelection(name='85工程', option=option) - assert [item.search_name for item in attempted] == sorted(aliases) - assert all(item.ship_types == (ShipType.CV,) for item in attempted) - assert all(item.min_level == 100 for item in attempted) - assert open_page.call_args_list == [call(2), call(2)] - cancel_page.assert_called_once_with() - - def test_try_select_option_falls_back_to_standard_name(self): - page = BattlePreparationPage( - _make_ctx(MagicMock(spec=AndroidController), MagicMock()), - ) - aliases = {'别名甲': '85工程', '别名乙': '85工程'} - set_user_ship_name_aliases(aliases) - option = ShipSelector(name='85工程') - choose_pages = [MagicMock(), MagicMock(), MagicMock()] - for choose_page in choose_pages[:-1]: - choose_page.change_single_ship.return_value = None - choose_pages[-1].change_single_ship.return_value = '85工程' - - with ( - patch.object( - page, - '_open_choose_page', - side_effect=choose_pages, - ), - patch.object(page, '_cancel_choose_page') as cancel_page, - ): - selected = page._try_select_option(0, option) - - attempted = [ - choose_page.change_single_ship.call_args.args[0].search_name - for choose_page in choose_pages - ] - assert selected == _ShipSelection(name='85工程', option=option) - assert attempted == [*sorted(aliases), '85工程'] - assert cancel_page.call_count == 2 - - def test_explicit_search_name_is_not_expanded(self): - page = BattlePreparationPage( - _make_ctx(MagicMock(spec=AndroidController), MagicMock()), - ) - set_user_ship_name_aliases({'别名甲': '85工程', '别名乙': '85工程'}) - option = ShipSelector(name='85工程', search_name='别名乙') - - assert page._search_options(option) == (option,) - - # ───────────────────────────────────────────── # 智能换船 # ───────────────────────────────────────────── diff --git a/testing/ui/event_page/test_unit.py b/testing/ui/event_page/test_unit.py new file mode 100644 index 00000000..d414d38d --- /dev/null +++ b/testing/ui/event_page/test_unit.py @@ -0,0 +1,270 @@ +"""活动地图页面控制器的无设备单元测试 (mock, 不连真机)。 + +覆盖三个关键路径: + - ``is_current_page``: 三层锚点 (出击按钮浮层态 / 难度图标干净页 / 标题兜底) + - ``_enter_node``: 出击按钮出现确认节点选择成功 (单帧, 替代旧双帧浮层检测) + - ``go_back``: 纯模板驱动 (出击按钮可见 = 浮层在 → 点红色 X; 否则点返回) + - ``ensure_no_overlay``: 出击按钮可见 = 浮层在 → 点 X → 按钮消失确认 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +import numpy as np +import pytest + +from autowsgr.infra.exceptions import ActionFailedError +from autowsgr.types import PageName +from autowsgr.ui.event.event_page import ( + CLICK_BACK, + CLICK_CLOSE_NODE_OVERLAY, + NODE_POSITIONS_BY_EVENT, + BaseEventPage, +) +from autowsgr.vision import ImageMatchDetail + + +def _gradient_screen(lo: int = 80, hi: int = 180) -> np.ndarray: + """连续渐变图 (模拟真实 UI 背景)。""" + xs = np.tile(np.linspace(0, 1, 960), (540, 1)) + return ((lo + xs * (hi - lo))[:, :, None].repeat(3, 2)).astype(np.uint8) + + +def _button_detail(confidence: float = 0.9) -> ImageMatchDetail: + """构造出击按钮匹配结果。""" + return ImageMatchDetail( + template_name='fight_button', + confidence=confidence, + center=(0.83, 0.84), + top_left=(0.78, 0.80), + bottom_right=(0.88, 0.88), + ) + + +def _make_page() -> tuple[BaseEventPage, MagicMock]: + """构造一个绑定 mock 控制器的 BaseEventPage。""" + ctx = MagicMock() + page = BaseEventPage(ctx, event_name='20260730') + return page, ctx.ctrl + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """屏蔽真实 sleep, 加速 _enter_node/go_back 的等待循环。""" + monkeypatch.setattr('autowsgr.ui.event.event_page.time.sleep', lambda *_: None) + + +def _mock_fight_button( + monkeypatch: pytest.MonkeyPatch, details: list[ImageMatchDetail | None] +) -> None: + """按帧序列 mock 出击按钮匹配 (None = 按钮不可见/浮层未开)。""" + monkeypatch.setattr( + BaseEventPage, + '_fight_button_detail', + staticmethod(MagicMock(side_effect=details)), + ) + + +# ───────────────────────────────────────────── +# is_current_page (三层锚点) +# ───────────────────────────────────────────── + + +class TestIsCurrentPage: + """背景 (实机 2026-08-15 日志): 战后活动页落浮层态, 标题仍命中 (0.928) + 但难度图标被遮挡, 无法区分干净页/浮层页 → 误判连锁。""" + + def test_fight_button_means_overlay_state(self, monkeypatch: pytest.MonkeyPatch) -> None: + """出击按钮可见 → 浮层态也算活动页 (战后 goto_page(EVENT_MAP) 不失败)。""" + _mock_fight_button(monkeypatch, [_button_detail()]) + result = BaseEventPage.is_current_page(_gradient_screen()) + assert result.matched + assert result.score == pytest.approx(0.9) + + def test_difficulty_icon_means_clean_page(self, monkeypatch: pytest.MonkeyPatch) -> None: + """难度图标可见 (无出击按钮) → 干净活动页。""" + _mock_fight_button(monkeypatch, [None]) + monkeypatch.setattr( + BaseEventPage, + '_difficulty_icon_state', + staticmethod(MagicMock(return_value='H')), + ) + result = BaseEventPage.is_current_page(_gradient_screen()) + assert result.matched + + def test_title_fallback_below_anchors(self, monkeypatch: pytest.MonkeyPatch) -> None: + """按钮/图标都不可见, 标题命中 → 兜底 matched, 但 score 低于锚点层。""" + _mock_fight_button(monkeypatch, [None]) + monkeypatch.setattr( + BaseEventPage, + '_difficulty_icon_state', + staticmethod(MagicMock(return_value=None)), + ) + from autowsgr.ui.event import event_page as ep + + monkeypatch.setattr( + ep, + '_get_event_title_templates', + list, + ) + # 标题模板为空 → find_any 返回 None → 不匹配 (新活动未截标题图的场景) + result = BaseEventPage.is_current_page(_gradient_screen()) + assert not result.matched + + def test_no_anchor_no_match(self, monkeypatch: pytest.MonkeyPatch) -> None: + """三层锚点全不命中 → 不是活动页。""" + _mock_fight_button(monkeypatch, [None]) + monkeypatch.setattr( + BaseEventPage, + '_difficulty_icon_state', + staticmethod(MagicMock(return_value=None)), + ) + result = BaseEventPage.is_current_page(_gradient_screen()) + assert not result.matched + assert result.name == str(PageName.EVENT_MAP) + + +# ───────────────────────────────────────────── +# _enter_node (出击按钮出现 = 浮层弹出) +# ───────────────────────────────────────────── + + +class TestEnterNode: + def test_success_detects_fight_button(self, monkeypatch: pytest.MonkeyPatch) -> None: + """点击节点后出击按钮出现 → 选择成功, 第 1 次复查即停。""" + page, ctrl = _make_page() + _mock_fight_button(monkeypatch, [_button_detail()]) + + page._enter_node(1) + + x, y = NODE_POSITIONS_BY_EVENT['20260730'][1] + ctrl.click.assert_called_once_with(x, y) + assert ctrl.screenshot.call_count == 1 + + def test_failure_raises_when_button_never_appears( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """按钮始终不出现 (浮层未弹出) → 循环耗尽 → ActionFailedError。""" + page, ctrl = _make_page() + _mock_fight_button(monkeypatch, [None] * 10) + + with pytest.raises(ActionFailedError): + page._enter_node(1) + assert ctrl.screenshot.call_count == 10 + + +# ───────────────────────────────────────────── +# go_back (纯模板驱动) +# ───────────────────────────────────────────── + + +class TestGoBack: + def test_already_at_main_returns(self, monkeypatch: pytest.MonkeyPatch) -> None: + """已在主页面 → 直接返回, 不点击。""" + page, ctrl = _make_page() + monkeypatch.setattr( + 'autowsgr.ui.main_page.MainPage.is_current_page', MagicMock(return_value=True) + ) + + page.go_back() + + ctrl.click.assert_not_called() + + def test_overlay_visible_closes_first(self, monkeypatch: pytest.MonkeyPatch) -> None: + """出击按钮可见 (关卡浮层在) → 点红色 X 而非点返回, 下轮到达主页。""" + page, ctrl = _make_page() + frame = _gradient_screen() + main_frame = _gradient_screen(lo=200, hi=210) + # 第一帧按钮可见 (浮层在) → 点 X; 第二帧干净 → 点返回; 轮询帧到主页 + ctrl.screenshot.side_effect = [frame, frame, main_frame] + _mock_fight_button(monkeypatch, [_button_detail(), None]) + monkeypatch.setattr( + 'autowsgr.ui.main_page.MainPage.is_current_page', + MagicMock(side_effect=[False, False, True]), + ) + + page.go_back() + + assert ctrl.click.call_args_list == [ + call(*CLICK_CLOSE_NODE_OVERLAY), + call(*CLICK_BACK), + ] + + def test_back_effective_single_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + """干净页点返回生效, 轮询窗口内到主页 → 只点一次返回 (防连点过冲)。""" + page, ctrl = _make_page() + frame = _gradient_screen() + main_frame = _gradient_screen(lo=200, hi=210) + # 第一帧干净 → 点返回; 轮询帧到主页 → 返回 + ctrl.screenshot.side_effect = [frame, main_frame] + _mock_fight_button(monkeypatch, [None]) + monkeypatch.setattr( + 'autowsgr.ui.main_page.MainPage.is_current_page', + MagicMock(side_effect=[False, True]), + ) + + page.go_back() + + assert ctrl.click.call_args_list == [call(*CLICK_BACK)] + + def test_timeout_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """始终不在主页且无浮层 → 15s 超时 → NavigationError。""" + from itertools import count + + from autowsgr.ui.utils import NavigationError + + page, ctrl = _make_page() + ctrl.screenshot.return_value = _gradient_screen() + monkeypatch.setattr( + 'autowsgr.ui.main_page.MainPage.is_current_page', MagicMock(return_value=False) + ) + monkeypatch.setattr( + BaseEventPage, '_fight_button_detail', staticmethod(MagicMock(return_value=None)) + ) + # time.sleep 已被 _no_sleep 屏蔽; mock monotonic 递增驱动超时 + monkeypatch.setattr( + 'autowsgr.ui.event.event_page.time.monotonic', + MagicMock(side_effect=count(0, 1)), + ) + + with pytest.raises(NavigationError): + page.go_back() + + +# ───────────────────────────────────────────── +# ensure_no_overlay (战后浮层清理: 出击按钮锚点) +# ───────────────────────────────────────────── + + +class TestEnsureNoOverlay: + """背景 (实机 2026-08-15 日志): H5 打完一场回港, 活动页直接落在关卡详情 + 浮层态 (战后 UI 流转跳过出征准备页), 模态浮层拦截一切点击 → 死循环。""" + + def test_clean_page_noop(self, monkeypatch: pytest.MonkeyPatch) -> None: + """出击按钮不可见 → 已是干净页, 不点击。""" + page, ctrl = _make_page() + ctrl.screenshot.return_value = _gradient_screen() + _mock_fight_button(monkeypatch, [None]) + + page.ensure_no_overlay() + + ctrl.click.assert_not_called() + + def test_overlay_closed_then_button_gone(self, monkeypatch: pytest.MonkeyPatch) -> None: + """按钮可见 (浮层在) → 点 X 后按钮消失 → 关闭成功, 只点一次。""" + page, ctrl = _make_page() + _mock_fight_button(monkeypatch, [_button_detail(), None]) + + page.ensure_no_overlay() + + assert ctrl.click.call_args_list == [call(*CLICK_CLOSE_NODE_OVERLAY)] + + def test_gives_up_after_three_rounds(self, monkeypatch: pytest.MonkeyPatch) -> None: + """按钮一直可见 (点 X 无效) → 3 轮后放弃。""" + page, ctrl = _make_page() + _mock_fight_button(monkeypatch, [_button_detail()] * 4) + + page.ensure_no_overlay() + + assert ctrl.click.call_count == 3 diff --git a/testing/ui/main_page/test_overlays_unit.py b/testing/ui/main_page/test_overlays_unit.py index 8b22f500..ada81194 100644 --- a/testing/ui/main_page/test_overlays_unit.py +++ b/testing/ui/main_page/test_overlays_unit.py @@ -9,6 +9,7 @@ import autowsgr.ui.utils as ui_utils from autowsgr.ui.main_page import overlays from autowsgr.ui.main_page.constants import DismissCoord +from autowsgr.vision import ImageChecker @pytest.mark.parametrize('second_confirmed', [False, True]) @@ -32,3 +33,31 @@ def test_dismiss_sign_handles_optional_second_confirmation( call(ctrl, must_confirm=False, timeout=overlays._SIGN_CONFIRM_TIMEOUT), ] sleep.assert_called_once_with(overlays._SIGN_CONFIRM_WAIT) + + +@pytest.mark.parametrize( + ('template_name', 'expected'), + [ + ('overlay_news', overlays.OverlayKind.NEWS), + ('overlay_sign', overlays.OverlayKind.SIGN), + ('overlay_booking', overlays.OverlayKind.BOOKING), + ('overlay_user_info', overlays.OverlayKind.USER_INFO), + ], +) +def test_detect_overlay_uses_templates( + monkeypatch: pytest.MonkeyPatch, + *, + template_name: str, + expected: overlays.OverlayKind, +) -> None: + """浮层检测改用图像模板匹配 (含 USER_INFO 新增分支)。""" + monkeypatch.setattr( + ImageChecker, 'template_exists', lambda _s, t, **_k: t.name == template_name + ) + assert overlays.detect_overlay(MagicMock()) is expected + + +def test_detect_overlay_none_when_no_match(monkeypatch: pytest.MonkeyPatch) -> None: + """无浮层命中时返回 None。""" + monkeypatch.setattr(ImageChecker, 'template_exists', lambda *_a, **_k: False) + assert overlays.detect_overlay(MagicMock()) is None diff --git a/testing/ui/map_page/test_unit.py b/testing/ui/map_page/test_unit.py index 83c58850..c04b749f 100644 --- a/testing/ui/map_page/test_unit.py +++ b/testing/ui/map_page/test_unit.py @@ -2,18 +2,23 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call, patch +import numpy as np import pytest from autowsgr.context import GameContext from autowsgr.emulator import AndroidController from autowsgr.ui.map.data import ( + ChapterSlot, CHAPTER_MAP_COUNTS, MAP_DATABASE, + choose_chapter_slot, + parse_chapter_label, parse_map_title, ) from autowsgr.ui.map.page import MapPage +from autowsgr.vision import OCRResult # ───────────────────────────────────────────── @@ -157,6 +162,41 @@ def test_total_map_count(self): assert len(MAP_DATABASE) >= 40 +class TestChapterSlots: + @staticmethod + def slots(values: list[int | None]) -> tuple[ChapterSlot, ...]: + return tuple( + ChapterSlot(index=i, chapter=chapter, text='', confidence=1.0) + for i, chapter in enumerate(values) + ) + + def test_parse_labels_and_placeholder(self): + assert parse_chapter_label('第六章') == 6 + assert parse_chapter_label('第10章') == 10 + assert parse_chapter_label('---') is None + assert parse_chapter_label('3十章') is None + + def test_prefers_visible_target(self): + slots = self.slots([8, 9, 10, None, None]) + assert choose_chapter_slot(10, 9, slots) == 1 + + def test_uses_two_slot_jump_when_available(self): + slots = self.slots([8, 9, 10, None, None]) + assert choose_chapter_slot(10, 5, slots) == 0 + + def test_uses_one_slot_jump_after_overshoot(self): + slots = self.slots([4, 5, 6, 7, 8]) + assert choose_chapter_slot(6, 5, slots) == 1 + + def test_uses_two_slot_jump_at_lower_boundary(self): + slots = self.slots([None, None, 1, 2, 3]) + assert choose_chapter_slot(1, 5, slots) == 4 + + def test_refuses_placeholder_slot(self): + slots = self.slots([None, 9, 10, None, None]) + assert choose_chapter_slot(10, 8, slots) is None + + # ───────────────────────────────────────────── # 动作 — 章节导航 # ───────────────────────────────────────────── @@ -179,3 +219,38 @@ def test_no_ocr_raises(self): pg = MapPage(ctx) with pytest.raises(RuntimeError, match='OCR'): pg.navigate_to_chapter(5) + + def test_fixed_slots_navigate_by_two_then_one(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + ctx = GameContext(ctrl=ctrl, config=MagicMock(), ocr=ocr) + pg = MapPage(ctx) + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + ctrl.screenshot.return_value = screen + + slot_reads = [ + ['第八章', '第九章', '第十章', '---', '---'], + ['第六章', '第七章', '第八章', '第九章', '第十章'], + ['第四章', '第五章', '第六章', '第七章', '第八章'], + ['第三章', '第四章', '第五章', '第六章', '第七章'], + ['第三章', '第四章', '第五章', '第六章', '第七章'], + ] + ocr.recognize_single.side_effect = [ + OCRResult(text=text, confidence=0.95) + for row in slot_reads + for text in row + ] + ocr.recognize_maxlen.side_effect = [ + OCRResult(text=text, confidence=0.95) + for text in ['10-1', '8-1', '6-1', '5-3', '5-3'] + ] + + with patch('autowsgr.ui.map.panels.sortie.time.sleep'): + result = pg.navigate_to_chapter(5) + + assert result == 5 + assert ctrl.click.call_args_list == [ + call(0.1, 0.31), + call(0.1, 0.31), + call(0.1, 0.43), + ] diff --git a/testing/ui/page/test_ui_stack_unit.py b/testing/ui/page/test_ui_stack_unit.py new file mode 100644 index 00000000..341cba53 --- /dev/null +++ b/testing/ui/page/test_ui_stack_unit.py @@ -0,0 +1,249 @@ +"""UIStack 导航栈的无设备单元测试。 + +拓扑断言依赖真实 NAV_GRAPH: + MAIN 邻居 = {MAP, MISSION, BACKYARD, SIDEBAR, EVENT_MAP} + BACKYARD 邻居 = {MAIN, BATH, CANTEEN} + BATH 邻居 = {BACKYARD} + MAP 邻居 = {MAIN, DECISIVE_BATTLE} +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from autowsgr.types import PageName as P +from autowsgr.ui import stack as stack_mod +from autowsgr.ui.stack import UIStack + + +if TYPE_CHECKING: + import pytest + + +def _frame(marker: int) -> np.ndarray: + """构造可区分的假截图。""" + return np.full((2, 2, 3), marker, dtype=np.uint8) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 栈操作 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_empty_stack_query() -> None: + """空栈:current/parent 为 None,pop 返回 None,候选集为空。""" + s = UIStack() + assert s.current is None + assert s.parent is None + assert s.pop() is None + assert s.candidates() == set() + assert s.candidates(P.BATH) == {P.BATH.value, P.BACKYARD.value} + + +def test_push_updates_current_parent() -> None: + s = UIStack() + s.push(P.MAIN) + assert (s.current, s.parent, s.depth) == (P.MAIN.value, None, 1) + s.push(P.BACKYARD) + assert (s.current, s.parent, s.depth) == (P.BACKYARD.value, P.MAIN.value, 2) + + +def test_push_same_page_refreshes_not_duplicates() -> None: + """同页重入 (如浮层开合) 不叠重复帧。""" + s = UIStack() + s.push(P.MAIN, screen=_frame(1)) + s.push(P.MAIN, screen=_frame(2)) + assert s.depth == 1 + assert s.snapshot(P.MAIN) is not None + + +def test_pop_returns_top_and_shrinks() -> None: + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + assert s.pop() == P.BACKYARD.value + assert s.current == P.MAIN.value + + +def test_max_depth_trims_oldest() -> None: + """超过 max_depth 时丢弃最老帧。""" + s = UIStack(max_depth=3) + for page in (P.MAIN, P.BACKYARD, P.BATH, P.MAIN, P.BACKYARD): + s.push(page) + assert s.pages() == (P.BATH.value, P.MAIN.value, P.BACKYARD.value) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 候选集 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_candidates_formula() -> None: + """candidates = {current} + neighbors(current) + {parent} + {target} + neighbors(target)。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + # current=BACKYARD 邻居 {MAIN,BATH,CANTEEN};parent=MAIN;target=BATH 邻居 {BACKYARD} + expected = ( + {P.BACKYARD.value, P.MAIN.value, P.BATH.value, P.CANTEEN.value} + | {P.MAIN.value} + | {P.BATH.value, P.BACKYARD.value} + ) + assert s.candidates(P.BATH) == expected + + +def test_candidates_includes_parent_for_leaf_pages() -> None: + """无入边叶子页 (BATTLE_PREP) 的来路只在栈中:parent 必须进候选。""" + s = UIStack() + s.push(P.MAP) + s.push(P.BATTLE_PREP) + assert s.parent == P.MAP.value + assert P.MAP.value in s.candidates() + + +def test_candidates_accepts_page_name_and_str() -> None: + s = UIStack() + s.push(P.MAIN) + assert s.candidates(P.MAP) == s.candidates(P.MAP.value) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# observe 对账四分支 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_observe_empty_stack_pushes_root() -> None: + s = UIStack() + assert s.observe(P.BACKYARD) == P.BACKYARD.value + assert s.pages() == (P.BACKYARD.value,) + + +def test_observe_same_page_refreshes_frame() -> None: + s = UIStack() + s.push(P.BACKYARD, screen=_frame(1)) + assert s.observe(P.BACKYARD, screen=_frame(2)) == P.BACKYARD.value + assert s.depth == 1 + assert s.snapshot(P.BACKYARD) is not None + + +def test_observe_parent_pops() -> None: + """识别为父页 → 自然回退,pop 回收来路。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + s.push(P.BATH) + assert s.observe(P.BACKYARD) == P.BACKYARD.value + assert s.pages() == (P.MAIN.value, P.BACKYARD.value) + + +def test_observe_parent_branch_wins_over_neighbor() -> None: + """父页同时在 neighbors(current) 中 (双向边 MAP↔MAIN) 时,优先 pop 而非 push。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.MAP) # MAIN ∈ neighbors(MAP) (双向边 MAP→MAIN) + assert s.observe(P.MAIN) == P.MAIN.value + assert s.pages() == (P.MAIN.value,) # pop 而非 [MAIN, MAP, MAIN] + + +def test_observe_neighbor_pushes() -> None: + s = UIStack() + s.push(P.MAIN) + assert s.observe(P.BACKYARD) == P.BACKYARD.value + assert s.pages() == (P.MAIN.value, P.BACKYARD.value) + + +def test_observe_drift_marks_and_keeps_stack() -> None: + """识别结果既非当前/父页也非邻居 → drifted,栈不动。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) # neighbors = {MAIN, BATH, CANTEEN} + assert s.observe(P.MAP) == P.BACKYARD.value + assert s.pages() == (P.MAIN.value, P.BACKYARD.value) + assert s.drifted is True + + +def test_drifted_cleared_by_reset_and_replace() -> None: + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + s.observe(P.MAP) # drift + assert s.drifted + s.replace(P.BACKYARD) + assert not s.drifted + s.observe(P.MAP) + assert s.drifted + s.reset(P.MAIN) + assert not s.drifted + assert s.pages() == (P.MAIN.value,) + + +def test_replace_swaps_top() -> None: + """replace 用于同层 tab 切换 / 人工校正。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + s.replace(P.CANTEEN) + assert s.pages() == (P.MAIN.value, P.CANTEEN.value) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# resync / 留存帧 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_resync_rebuilds_single_frame_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resync:全量识别 → 单帧根重建,不猜祖先链。""" + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + s.observe(P.MAP) + assert s.drifted + monkeypatch.setattr('autowsgr.ui.page.get_current_page', lambda _screen: P.CANTEEN.value) + screen = _frame(9) + assert s.resync(screen) == P.CANTEEN.value + assert s.pages() == (P.CANTEEN.value,) + assert not s.drifted + assert s.snapshot(P.CANTEEN) is screen + + +def test_resync_unidentified_clears_stack(monkeypatch: pytest.MonkeyPatch) -> None: + s = UIStack() + s.push(P.MAIN) + s.push(P.BACKYARD) + monkeypatch.setattr('autowsgr.ui.page.get_current_page', lambda _screen: None) + assert s.resync(_frame(0)) is None + assert s.pages() == () + assert not s.drifted + + +def test_snapshot_keeps_only_recent_frames() -> None: + """留存帧上限:仅最近 keep_frames 帧保留截图,更早帧只留页面名。""" + s = UIStack(keep_frames=2) + f1, f2, f3 = _frame(1), _frame(2), _frame(3) + s.push(P.MAIN, screen=f1) + s.push(P.BACKYARD, screen=f2) + s.push(P.BATH, screen=f3) + assert s.snapshot(P.BATH) is f3 + assert s.snapshot(P.BACKYARD) is f2 + assert s.snapshot(P.MAIN) is None # 超出 keep_frames 被淘汰 + + +def test_snapshot_returns_most_recent_frame_of_page() -> None: + s = UIStack() + f1, f2 = _frame(1), _frame(2) + s.push(P.MAIN, screen=f1) + s.push(P.BACKYARD) + s.push(P.MAIN, screen=f2) # 重入 MAIN + assert s.snapshot(P.MAIN) is f2 + + +def test_overlay_candidates_extend_formula(monkeypatch: pytest.MonkeyPatch) -> None: + """OVERLAY_CANDIDATES (当前为空) 若有内容应并入候选集。""" + s = UIStack() + s.push(P.MAIN) + monkeypatch.setattr(stack_mod, 'OVERLAY_CANDIDATES', {'network_error'}) + assert 'network_error' in s.candidates() diff --git a/testing/ui/page/test_unit.py b/testing/ui/page/test_unit.py index 83485a93..8e185a53 100644 --- a/testing/ui/page/test_unit.py +++ b/testing/ui/page/test_unit.py @@ -9,6 +9,7 @@ from autowsgr.emulator import AndroidController from autowsgr.ui.page import ( + _OVERLAY_PAGES, _PAGE_REGISTRY, get_current_page, register_page, @@ -17,6 +18,7 @@ NavigationError, wait_for_page, ) +from autowsgr.vision import PageMatch _W, _H = 960, 540 @@ -38,11 +40,14 @@ def _white() -> np.ndarray: class TestGetCurrentPage: def setup_method(self): self._backup = dict(_PAGE_REGISTRY) + self._backup_overlay = set(_OVERLAY_PAGES) _PAGE_REGISTRY.clear() def teardown_method(self): _PAGE_REGISTRY.clear() _PAGE_REGISTRY.update(self._backup) + _OVERLAY_PAGES.clear() + _OVERLAY_PAGES.update(self._backup_overlay) def test_returns_first_match(self): register_page('always_true', lambda _s: True) @@ -67,6 +72,78 @@ def bad_checker(_s: np.ndarray): register_page('good', lambda _s: True) assert get_current_page(_blank()) == 'good' + def test_candidate_filtering(self): + """candidates 限制只评估候选页:未在候选集的真页不返回。""" + register_page('a', lambda _s: True) + register_page('b', lambda _s: True) + assert get_current_page(_blank(), candidates={'a'}) == 'a' + assert get_current_page(_blank(), candidates=set()) is None + + def test_score_ranking(self): + """命中多页时按 score 降序取最高分(而非注册顺序)。""" + register_page('low', lambda _s: PageMatch(name='low', matched=True, score=0.5)) + register_page('high', lambda _s: PageMatch(name='high', matched=True, score=0.9)) + assert get_current_page(_blank()) == 'high' + + def test_register_order_tiebreak(self): + """同分时按注册顺序(稳定排序)决胜。""" + register_page('first', lambda _s: PageMatch(name='first', matched=True, score=0.8)) + register_page('second', lambda _s: PageMatch(name='second', matched=True, score=0.8)) + assert get_current_page(_blank()) == 'first' + + def test_bool_checker_normalized(self): + """旧式 bool checker 归一化:True→score=1.0,胜过低分 PageMatch。""" + register_page('bool_true', lambda _s: True) + register_page('low_score', lambda _s: PageMatch(name='low_score', matched=True, score=0.7)) + assert get_current_page(_blank()) == 'bool_true' + + def test_unregistered_candidate_ignored(self): + """候选集中未注册的名称被静默跳过。""" + register_page('only', lambda _s: True) + assert get_current_page(_blank(), candidates={'only', 'ghost'}) == 'only' + + def test_overlay_page_wins_over_higher_score_base(self): + """覆盖型页面命中时优先于更高分的底页 (z-order 优先于分数)。 + + 实机场景: 侧边栏抽屉打开时不遮挡主页面识别元素, 主页面 0.988 与 + 侧边栏 ~0.86 同时命中 — 纯分数排序误判为主页面, 导航反复点切换 + 按钮把侧边栏开了又关 (2026-08-16 解装导航死循环)。 + """ + register_page('base', lambda _s: PageMatch(name='base', matched=True, score=0.988)) + register_page( + 'drawer', lambda _s: PageMatch(name='drawer', matched=True, score=0.86), overlay=True + ) + assert get_current_page(_blank()) == 'drawer' + + def test_overlay_page_absent_falls_back_to_base(self): + """覆盖型页面未命中时正常回退底页最高分。""" + register_page('base', lambda _s: PageMatch(name='base', matched=True, score=0.988)) + register_page( + 'drawer', lambda _s: PageMatch(name='drawer', matched=False, score=0.0), overlay=True + ) + assert get_current_page(_blank()) == 'base' + + def test_overlay_pages_ranked_by_score(self): + """多个覆盖型同时命中时, 覆盖集内按分数降序。""" + register_page( + 'drawer_low', + lambda _s: PageMatch(name='drawer_low', matched=True, score=0.82), + overlay=True, + ) + register_page( + 'drawer_high', + lambda _s: PageMatch(name='drawer_high', matched=True, score=0.86), + overlay=True, + ) + assert get_current_page(_blank()) == 'drawer_high' + + def test_overlay_flag_reregistration_updates(self): + """重注册同名页面时 overlay 标记同步更新 (先 overlay 后普通则移出集合)。""" + register_page('page', lambda _s: True, overlay=True) + assert 'page' in _OVERLAY_PAGES + register_page('page', lambda _s: True) + assert 'page' not in _OVERLAY_PAGES + # ───────────────────────────────────────────── # wait_for_page diff --git a/testing/ui/test_ship_list.py b/testing/ui/test_ship_list.py index 6208115d..10410912 100644 --- a/testing/ui/test_ship_list.py +++ b/testing/ui/test_ship_list.py @@ -10,7 +10,6 @@ _probe_level_near_name, locate_ship_rows, read_ship_level_at_card, - read_ship_levels, ) from autowsgr.vision import OCREngine, OCRResult from autowsgr.vision.ocr import EasyOCREngine @@ -272,91 +271,3 @@ def test_read_ship_level_at_card_converts_relative_card_position( name_x=320, max_x=1048, ) - - -def test_read_ship_levels_probes_level_from_upscaled_name_position( - monkeypatch: pytest.MonkeyPatch, -): - screen = _single_row_screen(monkeypatch) - ocr = MagicMock() - ocr.recognize.side_effect = [ - [OCRResult(text='Lv.110', confidence=0.99, bbox=(550, 2, 650, 18))], - [OCRResult(text='火力', confidence=0.9, bbox=(1100, 4, 1300, 36))], - ] - probe = MagicMock(return_value=103) - monkeypatch.setattr('autowsgr.ui.utils.ship_list._probe_level_near_name', probe) - - assert read_ship_levels(ocr, screen) == [('火力', 103)] - assert ocr.recognize.call_count == 2 - probe.assert_called_once_with( - ocr, - screen, - y_start=99, - y_end=121, - name_x=600, - max_x=1048, - ) - - -def test_read_ship_levels_applies_alias_before_card_level_probe( - monkeypatch: pytest.MonkeyPatch, -): - screen = _single_row_screen(monkeypatch) - ocr = MagicMock() - ocr.recognize.return_value = [ - OCRResult(text='希尔德布兰德', confidence=0.99, bbox=(200, 2, 320, 18)), - OCRResult(text='Lv.110', confidence=0.99, bbox=(330, 2, 390, 18)), - ] - set_user_ship_name_aliases({'希尔德布兰德': 'AIII'}) - probe = MagicMock(return_value=101) - monkeypatch.setattr('autowsgr.ui.utils.ship_list._probe_level_near_name', probe) - - found = read_ship_levels(ocr, screen) - - assert found == [('AIII', 101)] - assert ocr.recognize.call_count == 1 - probe.assert_called_once_with( - ocr, - screen, - y_start=99, - y_end=121, - name_x=260, - max_x=1048, - ) - - -def test_read_ship_levels_includes_card_position_for_binding( - monkeypatch: pytest.MonkeyPatch, -): - screen = _single_row_screen(monkeypatch) - ocr = MagicMock() - ocr.recognize.return_value = [ - OCRResult(text='昆西', confidence=0.99, bbox=(200, 2, 300, 18)), - OCRResult(text='Lv.110', confidence=0.99, bbox=(240, 2, 300, 18)), - ] - probe = MagicMock(return_value=1) - monkeypatch.setattr('autowsgr.ui.utils.ship_list._probe_level_near_name', probe) - - found = read_ship_levels( - ocr, - screen, - deduplicate_by_name=False, - include_row_key=True, - ) - - assert found == [ - ( - '昆西', - 1, - pytest.approx(250 / 1280), - pytest.approx(round(110 / 720, 4)), - ) - ] - probe.assert_called_once_with( - ocr, - screen, - y_start=99, - y_end=121, - name_x=250, - max_x=1048, - ) diff --git a/testing/ui/test_wait_leave_probe_unit.py b/testing/ui/test_wait_leave_probe_unit.py new file mode 100644 index 00000000..ad5d01bc --- /dev/null +++ b/testing/ui/test_wait_leave_probe_unit.py @@ -0,0 +1,56 @@ +"""wait_leave_page 探测模式 (probe=True) 的无设备单元测试。 + +背景 (实机 2026-08-15 日志): 战役次数用尽是预期分支, 但出征探测用的 +wait_leave_page 超时走 NavigationError 路径 — 构造该异常会 ERROR 记录 + +保存 NavError 截图, 即使调用方立刻捕获, 错误现场也已被污染。probe 模式 +让"超时"成为普通返回值 (None)。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from autowsgr.ui.utils.navigation import wait_leave_page + + +def _ctrl(stays: bool) -> MagicMock: + """构造 mock 控制器; *stays* 为 True 时截图永远匹配原页面。""" + ctrl = MagicMock() + frame = np.full((540, 960, 3), 200, dtype=np.uint8) + ctrl.screenshot.return_value = frame + ctrl.checker = MagicMock(return_value=stays) + return ctrl + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr('autowsgr.ui.utils.navigation.time.sleep', lambda *_: None) + + +class TestProbeMode: + def test_probe_timeout_returns_none_without_raising(self): + """probe=True 超时 → 返回 None 而非抛 NavigationError。""" + ctrl = _ctrl(stays=True) + result = wait_leave_page( + ctrl, + checker=lambda _: True, # 永远"仍在原页" + timeout=0, + probe=True, + ) + assert result is None + + def test_probe_left_returns_frame(self): + """probe 模式下正常离开 → 仍返回到达帧 (与普通模式一致)。""" + ctrl = _ctrl(stays=False) + result = wait_leave_page(ctrl, checker=lambda _: False, timeout=1, probe=True) + assert isinstance(result, np.ndarray) + + def test_default_mode_still_raises(self): + """probe=False (默认) 超时 → 照旧抛 NavigationError。""" + from autowsgr.ui.utils.navigation import NavigationError + + with pytest.raises(NavigationError): + wait_leave_page(ctrl=_ctrl(stays=True), checker=lambda _: True, timeout=0) diff --git a/testing/vision/test_image_checker.py b/testing/vision/test_image_checker.py index b9d8b774..60e8af7f 100644 --- a/testing/vision/test_image_checker.py +++ b/testing/vision/test_image_checker.py @@ -9,6 +9,7 @@ ImageChecker, ImageRule, ImageSignature, + ImageTemplate, MatchStrategy, ) @@ -491,3 +492,120 @@ def test_scale_template_if_needed_with_source_resolution(self): # source_resolution=None → fallback to global (960,540) same2 = ImageChecker._scale_template_if_needed(tmpl_img, 960, 540) assert same2 is tmpl_img + + +# ───────────────────────────────────────────── +# ImageChecker._unmask — 反蒙版还原 (纯像素运算) +# ───────────────────────────────────────────── + + +class TestUnmask: + """_unmask 反蒙版还原算法。 + + 还原半透明黑罩压暗的画面: ``RGB /= factor`` (clamp 255)。 + 纯像素运算, 与模板匹配无关; 匹配链中的透传见 :class:`TestUnmaskFactorPassthrough`。 + """ + + def test_passthrough_when_factor_zero(self): + """factor<=0 时零拷贝透传原图。""" + import numpy as np + + img = np.full((10, 20, 3), 100, dtype=np.uint8) + assert ImageChecker._unmask(img, 0.0) is img + + def test_passthrough_when_factor_negative(self): + """负 factor 同样透传 (禁用语义)。""" + import numpy as np + + img = np.full((10, 20, 3), 100, dtype=np.uint8) + assert ImageChecker._unmask(img, -1.0) is img + + def test_scales_brightness(self): + """factor=0.5 → 像素值翻倍 (还原被压暗到一半的画面)。""" + import numpy as np + + img = np.full((10, 20, 3), 100, dtype=np.uint8) + out = ImageChecker._unmask(img, 0.5) + assert out.dtype == np.uint8 + assert (out == 200).all() + + def test_clips_overflow_to_255(self): + """还原后超 255 的值 clamp 到 255。""" + import numpy as np + + img = np.full((10, 20, 3), 200, dtype=np.uint8) + out = ImageChecker._unmask(img, 0.5) # 200/0.5=400 -> 255 + assert (out == 255).all() + + def test_preserves_shape(self): + """输出保持 HxWx3 uint8 形状。""" + import numpy as np + + img = np.zeros((30, 40, 3), dtype=np.uint8) + out = ImageChecker._unmask(img, 0.33) + assert out.shape == (30, 40, 3) + + +# ───────────────────────────────────────────── +# unmask_factor — 匹配链透传 (不破坏现有匹配) +# ───────────────────────────────────────────── + + +class TestUnmaskFactorPassthrough: + """unmask_factor 在模板匹配链中的透传。 + + .. note:: + + ``TM_CCOEFF_NORMED`` 对纯乘性压暗具有缩放不变性 — 压暗截图的模板匹配置信度 + 本就接近原图 (~0.998), 故 unmask 对模板匹配置信度几乎无影响。unmask 的真正 + 价值在**像素级对比 (MAE)** (边缘对比方案), 而非模板匹配; 此处仅保证 + ``unmask_factor`` 接口在匹配链中安全透传、不破坏现有匹配。 + """ + + def test_find_template_with_unmask_on_darkened_screen(self): + """压暗截图 + unmask 还原后仍能匹配。""" + import numpy as np + + rng = np.random.RandomState(90) + tmpl_img = rng.randint(40, 120, (30, 40, 3)).astype(np.uint8) + tmpl = ImageTemplate(name='dark_tmpl', image=tmpl_img, source='test') + screen = solid_screen(120, 120, 120) + screen = embed_template_in_screen(screen, tmpl, x=200, y=150) + darkened = np.clip(screen.astype(np.float32) * 0.4, 0, 255).astype(np.uint8) + detail = ImageChecker.find_template(darkened, tmpl, confidence=0.85, unmask_factor=0.4) + assert detail is not None + assert detail.confidence > 0.85 + + def test_rule_unmask_factor_field_and_passthrough(self): + """ImageRule.unmask_factor 字段经 match_rule 透传不破坏匹配。""" + import numpy as np + + rng = np.random.RandomState(50) + tmpl_img = rng.randint(0, 256, (50, 80, 3)).astype(np.uint8) + tmpl = ImageTemplate(name='btn', image=tmpl_img, source='test') + screen = solid_screen(150, 150, 150) + screen = embed_template_in_screen(screen, tmpl, x=100, y=100) + darkened = np.clip(screen.astype(np.float32) * 0.4, 0, 255).astype(np.uint8) + rule = ImageRule(name='r', templates=[tmpl], confidence=0.85, unmask_factor=0.4) + assert rule.unmask_factor == 0.4 + result = ImageChecker.match_rule(darkened, rule) + assert result.matched + + def test_find_all_occurrences_accepts_unmask(self): + """find_all_occurrences 支持 unmask_factor。""" + import numpy as np + + screen = solid_screen(120, 120, 120) + tmpl = make_template(seed=60, h=20, w=30, name='icon') + screen = embed_template_in_screen(screen, tmpl, x=100, y=100) + screen = embed_template_in_screen(screen, tmpl, x=500, y=400) + darkened = np.clip(screen.astype(np.float32) * 0.4, 0, 255).astype(np.uint8) + results = ImageChecker.find_all_occurrences( + darkened, tmpl, confidence=0.9, min_distance=20, unmask_factor=0.4 + ) + assert len(results) >= 2 + + def test_unmask_factor_default_is_zero(self): + """ImageRule 默认 unmask_factor=0 (禁用)。""" + rule = ImageRule(name='r', templates=[make_template(seed=1)]) + assert rule.unmask_factor == 0.0 diff --git a/testing/vision/test_ocr.py b/testing/vision/test_ocr.py index f00d051a..60ee01e4 100644 --- a/testing/vision/test_ocr.py +++ b/testing/vision/test_ocr.py @@ -30,7 +30,6 @@ FastOCRProfile, get_easyocr_params, get_fastocr_params, - get_user_ship_name_aliases, normalize_level_digits, set_user_ship_name_aliases, set_user_ship_name_corrections, @@ -570,23 +569,6 @@ def test_user_ship_name_aliases_map_display_names_to_standard_names(self): assert _fuzzy_match(apply_ship_patches('U-47·狼群'), SHIPNAMES) == 'U-47' assert _fuzzy_match(apply_ship_patches('巴尔的摩:英魂'), SHIPNAMES) == '巴尔的摩' - @pytest.mark.parametrize( - 'aliases', - [ - {'别名甲': '85工程', '别名乙': '85工程'}, - {'别名乙': '85工程', '别名甲': '85工程'}, - ], - ) - def test_reverse_alias_lookup_returns_all_aliases_in_stable_order( - self, - aliases: dict[str, str], - ): - set_user_ship_name_aliases(aliases) - - expected = tuple(sorted(aliases)) - assert get_user_ship_name_aliases('85工程') == expected - assert get_user_ship_name_aliases(expected[0]) == (expected[0],) - def test_user_ship_name_is_added_to_the_same_ship_group(self): set_user_ship_name_aliases({'契卡洛夫': '85工程'}) diff --git a/testing/vision/test_roi.py b/testing/vision/test_roi.py index da1db5bb..e32922be 100644 --- a/testing/vision/test_roi.py +++ b/testing/vision/test_roi.py @@ -48,6 +48,10 @@ def test_to_absolute(self): assert (px1, py1) == (0, 0) assert (px2, py2) == (480, 270) + def test_expand_pixels(self): + roi = ROI(100 / 1280, 100 / 720, 200 / 1280, 200 / 720) + assert roi.expand_pixels(1280, 720).to_absolute(1280, 720) == (99, 99, 201, 201) + def test_crop(self): screen = solid_screen(100, 100, 100) roi = ROI(0.0, 0.0, 0.5, 0.5) diff --git a/tools/build_ocr_crop_tool.ps1 b/tools/build_ocr_crop_tool.ps1 new file mode 100644 index 00000000..1332888a --- /dev/null +++ b/tools/build_ocr_crop_tool.ps1 @@ -0,0 +1,82 @@ +param( + [string]$PackageRoot = "C:\ShiinaKuroko\01.Project\temp\AutoWSGR-OCR-Crop-Tool" +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$BuildRoot = Join-Path $RepoRoot ".tmp\ocr-crop-tool" +$DistRoot = Join-Path $BuildRoot "dist" +$WorkRoot = Join-Path $BuildRoot "work" +$AdbRoot = Join-Path $RepoRoot ".venv\Lib\site-packages\adbutils\binaries" +$EntryPoint = Join-Path $PSScriptRoot "ocr_crop_tool.py" + +if (Test-Path $PackageRoot) { + throw "目标目录已经存在,请先确认并移走旧版本:$PackageRoot" +} + +$RequiredAdbFiles = @( + "adb.exe", + "AdbWinApi.dll", + "AdbWinUsbApi.dll" +) +foreach ($FileName in $RequiredAdbFiles) { + $FilePath = Join-Path $AdbRoot $FileName + if (-not (Test-Path $FilePath)) { + throw "缺少 ADB 文件:$FilePath" + } +} + +New-Item -ItemType Directory -Path $BuildRoot -Force | Out-Null + +$PyInstallerArgs = @( + "run", + "--with", + "pyinstaller==6.16.0", + "pyinstaller", + "--noconfirm", + "--clean", + "--onedir", + "--console", + "--noupx", + "--name", + "main", + "--distpath", + $DistRoot, + "--workpath", + $WorkRoot, + "--specpath", + $BuildRoot, + "--collect-all", + "autowsgr_native", + "--hidden-import", + "autowsgr_native._native", + "--add-binary", + "$(Join-Path $AdbRoot 'adb.exe');adb", + "--add-binary", + "$(Join-Path $AdbRoot 'AdbWinApi.dll');adb", + "--add-binary", + "$(Join-Path $AdbRoot 'AdbWinUsbApi.dll');adb", + $EntryPoint +) + +Write-Host "正在生成 main.exe..." +& uv @PyInstallerArgs +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller 打包失败,退出码:$LASTEXITCODE" +} + +$GeneratedRoot = Join-Path $DistRoot "main" +if (-not (Test-Path (Join-Path $GeneratedRoot "main.exe"))) { + throw "打包完成但未找到 main.exe" +} + +Move-Item -Path $GeneratedRoot -Destination $PackageRoot +Copy-Item ` + -Path (Join-Path $PSScriptRoot "ocr_crop_tool_README.txt") ` + -Destination (Join-Path $PackageRoot "使用说明.txt") +Copy-Item ` + -Path (Join-Path $PSScriptRoot "ocr_crop_tool_start.cmd") ` + -Destination (Join-Path $PackageRoot "start-tool.cmd") + +Write-Host "工具已生成:$PackageRoot" diff --git a/tools/check_sortie_api.py b/tools/check_sortie_api.py new file mode 100644 index 00000000..095cd9b3 --- /dev/null +++ b/tools/check_sortie_api.py @@ -0,0 +1,162 @@ +"""重构契约回归检查: 确认 sortie.py 对外 API 与拆分方案一致。 + +运行: .venv\\Scripts\\python.exe tools\\check_sortie_api.py +返回 0=通过, 非0=失败。 +""" +from __future__ import annotations + +import inspect +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + + +def main() -> int: + errors: list[str] = [] + + # ── 1. counters 子模块必须存在(拆分后) ── + try: + from autowsgr.ui.map.panels import sortie_counters # noqa: F401 + print('[OK] sortie_counters.py 可导入') + except Exception as e: # noqa: BLE001 + errors.append(f'sortie_counters 不可导入: {e}') + + # ── 2. sortie.py 对外契约(3 调用方依赖)必须完全可导入 ── + # panels/__init__.py 依赖 + try: + from autowsgr.ui.map.panels.sortie import ( + LootShipCount, + SortiePanelMixin, + recognize_loot_count, + recognize_ship_count, + ) + print('[OK] panels/__init__ 需要的 4 符号可导入') + except Exception as e: # noqa: BLE001 + errors.append(f'panels/__init__ 导入失败: {e}') + + # campaign.py 依赖 + try: + from autowsgr.ui.map.panels.sortie import ( + LOOT_MAX, + SHIP_MAX, + recognize_loot_count, + recognize_ship_count, + ) + if LOOT_MAX != 50: + errors.append(f'LOOT_MAX={LOOT_MAX}, 期望 50') + if SHIP_MAX != 500: + errors.append(f'SHIP_MAX={SHIP_MAX}, 期望 500') + print('[OK] campaign 需要的 4 符号可导入 + 常量值正确') + except Exception as e: # noqa: BLE001 + errors.append(f'campaign 导入失败: {e}') + + # e2e_chapter_nav.py 依赖 + try: + from autowsgr.ui.map.panels.sortie import SortiePanelMixin # noqa: F811 + print('[OK] e2e 需要的 SortiePanelMixin 可导入') + except Exception as e: # noqa: BLE001 + errors.append(f'e2e 导入失败: {e}') + + # ── 3. 类注解 / 函数签名验证 ── + # LootShipCount: frozen dataclass 字段 loot/loot_max/ship/ship_max + try: + fields = LootShipCount.__dataclass_fields__ # type: ignore[attr-defined] + expected = ('loot', 'loot_max', 'ship', 'ship_max') + missing = [f for f in expected if f not in fields] + if missing: + errors.append(f'LootShipCount 缺字段: {missing}') + else: + print('[OK] LootShipCount 字段 loot/loot_max/ship/ship_max 齐备') + except Exception as e: # noqa: BLE001 + errors.append(f'LootShipCount 字段检查失败: {e}') + + # recognize_loot_count(screen, ocr) -> int | None + try: + sig = inspect.signature(recognize_loot_count) + params = list(sig.parameters.keys()) + if params != ['screen', 'ocr']: + errors.append(f'recognize_loot_count 签名参数={params}, 期望 [screen, ocr]') + else: + print('[OK] recognize_loot_count(screen, ocr) 签名正确') + except Exception as e: # noqa: BLE001 + errors.append(f'recognize_loot_count 签名检查失败: {e}') + + # recognize_ship_count(screen, ocr) -> int | None + try: + sig = inspect.signature(recognize_ship_count) + params = list(sig.parameters.keys()) + if params != ['screen', 'ocr']: + errors.append(f'recognize_ship_count 签名参数={params}, 期望 [screen, ocr]') + else: + print('[OK] recognize_ship_count(screen, ocr) 签名正确') + except Exception as e: # noqa: BLE001 + errors.append(f'recognize_ship_count 签名检查失败: {e}') + + # SortiePanelMixin: enter_sortie / navigate_to_chapter / navigate_to_map / + # recognize_map / get_loot_and_ship_count / click_chapter 必须存在 + required_methods = [ + 'enter_sortie', + 'navigate_to_chapter', + 'navigate_to_map', + 'recognize_map', + 'get_loot_and_ship_count', + 'click_chapter', + ] + missing_methods = [m for m in required_methods if not hasattr(SortiePanelMixin, m)] + if missing_methods: + errors.append(f'SortiePanelMixin 缺方法: {missing_methods}') + else: + print('[OK] SortiePanelMixin 6 个必需方法齐备') + + # enter_sortie 参数: (chapter, map_num) + try: + sig = inspect.signature(SortiePanelMixin.enter_sortie) + params = list(sig.parameters.keys()) + if params != ['self', 'chapter', 'map_num']: + errors.append(f'enter_sortie 参数={params} != [self,chapter,map_num]') + else: + print('[OK] enter_sortie(self, chapter, map_num) 签名正确') + except Exception as e: # noqa: BLE001 + errors.append(f'enter_sortie 签名检查失败: {e}') + + # ── 4. sortie_counters 必须真实导出同名符号(确保真的拆出去了) ── + try: + import autowsgr.ui.map.panels.sortie_counters as sc + required = ('LootShipCount', 'recognize_loot_count', 'recognize_ship_count', + 'LOOT_MAX', 'SHIP_MAX') + missing = [s for s in required if not hasattr(sc, s)] + if missing: + errors.append(f'sortie_counters 缺符号: {missing}') + else: + print('[OK] sortie_counters 真正导出了 5 个计数器符号') + # 必须与 sortie.py 重导出的是同一对象(不是重新定义的副本) + from autowsgr.ui.map.panels import sortie as so + if sc.LOOT_MAX != so.LOOT_MAX or sc.SHIP_MAX != so.SHIP_MAX: + errors.append('sortie_counters 的常量值与 sortie.py 不一致') + if sc.LootShipCount is not so.LootShipCount: + errors.append('LootShipCount 在 sortie.py 不是 re-export(可能重复定义)') + if sc.recognize_loot_count is not so.recognize_loot_count: + errors.append('recognize_loot_count 在 sortie.py 不是 re-export') + if sc.recognize_ship_count is not so.recognize_ship_count: + errors.append('recognize_ship_count 在 sortie.py 不是 re-export') + print('[OK] sortie.py 的计数器 4 符号均是 sortie_counters 同对象 re-export') + except ImportError: + # 第 1 步已经报错,这里不重复 + pass + except Exception as e: # noqa: BLE001 + errors.append(f'sortie_counters 内容验证异常: {e}') + + # ── 汇总 ── + if errors: + print('\n[FAIL] 共 {} 项不符合契约:'.format(len(errors))) + for i, e in enumerate(errors, 1): + print(' {}. {}'.format(i, e)) + return 1 + print('\n[PASS] 所有 API 契约校验通过 ✓') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/debug_toolkit/README.md b/tools/debug_toolkit/README.md new file mode 100644 index 00000000..56bc2c05 --- /dev/null +++ b/tools/debug_toolkit/README.md @@ -0,0 +1,49 @@ +# Debug Toolkit + +This folder is the Agent-facing debug package. Run it from the repository root +with `uv run python tools/debug_toolkit/main.py ...` or +`uv run python -m tools.debug_toolkit ...`. + +```text +main.py +function/ + e2e.py + e2e_runner/ full copy of the original tools/e2e package + screenshot.py + roi.py + ocr.py +adb/ +result/ +``` + +## Commands + +```text +python tools/debug_toolkit/main.py screenshot --serial 127.0.0.1:16384 +python tools/debug_toolkit/main.py roi --image result/screenshot/screenshot_*.png --roi 0.25,0.33,0.47,0.60 +python tools/debug_toolkit/main.py ocr --image result/screenshot/screenshot_*/1x/screenshot_*_roi_1x.png +python tools/debug_toolkit/main.py e2e --serial 127.0.0.1:16384 --with-ocr decisive --times 1 +``` + +## Output + +```text +result/ + logs/ copied E2E run logs + screenshot/ timestamped screenshots and per-screenshot ROI folders + ocr/ JSON OCR results +``` + +For an input named `screenshot_20260909_120000.png`, ROI output is: + +```text +result/screenshot/screenshot_20260909_120000/ + screenshot_20260909_120000.png + 1x/screenshot_20260909_120000_roi_1x.png + 2x/screenshot_20260909_120000_roi_2x.png + 4x/screenshot_20260909_120000_roi_4x.png + 8x/screenshot_20260909_120000_roi_8x.png +``` + +The package includes `adb/adb.exe` and its Windows runtime DLLs. Existing +standalone tools and `tools/e2e` remain compatible. diff --git a/tools/debug_toolkit/__init__.py b/tools/debug_toolkit/__init__.py new file mode 100644 index 00000000..ef537c6b --- /dev/null +++ b/tools/debug_toolkit/__init__.py @@ -0,0 +1 @@ +"""Unified debug toolkit entrypoint for screenshot, ROI, and E2E checks.""" diff --git a/tools/debug_toolkit/__main__.py b/tools/debug_toolkit/__main__.py new file mode 100644 index 00000000..f1eb8dd4 --- /dev/null +++ b/tools/debug_toolkit/__main__.py @@ -0,0 +1,5 @@ +from .main import main + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/debug_toolkit/adb/AdbWinApi.dll b/tools/debug_toolkit/adb/AdbWinApi.dll new file mode 100644 index 00000000..1da794e8 Binary files /dev/null and b/tools/debug_toolkit/adb/AdbWinApi.dll differ diff --git a/tools/debug_toolkit/adb/AdbWinUsbApi.dll b/tools/debug_toolkit/adb/AdbWinUsbApi.dll new file mode 100644 index 00000000..7f75aec4 Binary files /dev/null and b/tools/debug_toolkit/adb/AdbWinUsbApi.dll differ diff --git a/tools/debug_toolkit/adb/adb.exe b/tools/debug_toolkit/adb/adb.exe new file mode 100644 index 00000000..34a0fd29 Binary files /dev/null and b/tools/debug_toolkit/adb/adb.exe differ diff --git a/tools/debug_toolkit/function/__init__.py b/tools/debug_toolkit/function/__init__.py new file mode 100644 index 00000000..7627aff6 --- /dev/null +++ b/tools/debug_toolkit/function/__init__.py @@ -0,0 +1 @@ +"""Debug function modules.""" diff --git a/tools/debug_toolkit/function/e2e.py b/tools/debug_toolkit/function/e2e.py new file mode 100644 index 00000000..265d6226 --- /dev/null +++ b/tools/debug_toolkit/function/e2e.py @@ -0,0 +1,36 @@ +"""Pass-through wrapper for the existing E2E runner.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = PACKAGE_ROOT.parents[1] +RESULT_LOG_ROOT = PACKAGE_ROOT / 'result' / 'logs' + + +def _copy_latest_log() -> None: + source_root = REPO_ROOT / 'logs' / 'e2e_tools' + candidates = [path for path in source_root.glob('*/*') if path.is_dir()] + if not candidates: + return + latest = max(candidates, key=lambda path: path.stat().st_mtime) + target = RESULT_LOG_ROOT / latest.parent.name / latest.name + shutil.copytree(latest, target, dirs_exist_ok=True) + print(f'log: {target}') + + +def run(arguments: list[str]) -> int: + command = [ + sys.executable, + str(PACKAGE_ROOT / 'function' / 'e2e_runner' / 'run.py'), + *arguments, + ] + completed = subprocess.run(command, cwd=REPO_ROOT, check=False) # noqa: S603 + if '--list' not in arguments: + _copy_latest_log() + return completed.returncode diff --git a/tools/debug_toolkit/function/e2e_runner/__init__.py b/tools/debug_toolkit/function/e2e_runner/__init__.py new file mode 100644 index 00000000..1fb955b2 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/__init__.py @@ -0,0 +1,10 @@ +"""E2E 快速实机验证工具包 (tools/e2e)。 + +用法:: + + python tools/e2e/run.py --list # 列出全部验证 case + python tools/e2e/run.py screenshot --no-launch # 链路自检 (不动游戏) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --times 1 + +新增加速验证: 在 cases/ 目录复制 template 改写 run() 即可, 无需改框架。 +""" diff --git a/tools/debug_toolkit/function/e2e_runner/cases/__init__.py b/tools/debug_toolkit/function/e2e_runner/cases/__init__.py new file mode 100644 index 00000000..8b1e5795 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/__init__.py @@ -0,0 +1,9 @@ +"""E2E 验证 case 目录。 + +每个 *.py 是一个可独立运行的实机验证脚本, 约定: +- 必须: ``def run(rt) -> bool`` — 步骤主体 (rt 是 E2ERunner 基座) +- 可选: ``DESC: str`` — 一句话描述 (--list 显示) +- 可选: ``def add_arguments(parser)`` — case 专属命令行参数 + +新增验证: 复制任意示例文件改写 run() 即可, 框架自动发现。 +""" diff --git a/tools/debug_toolkit/function/e2e_runner/cases/bath_repair.py b/tools/debug_toolkit/function/e2e_runner/cases/bath_repair.py new file mode 100644 index 00000000..67f51109 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/bath_repair.py @@ -0,0 +1,40 @@ +"""浴场修理链路 E2E — business.logistics.repair 实机验证。 + +验证链路契约: 首页 → 浴场 → 选择修理 overlay → 派修 → 回首页。 +覆盖点: + - goto_page 跨页导航 (浴场页位于 application/ui/controller/bath_page/) + - 选择修理 overlay 开关机制 (点击舰船后自动关闭) + - OCR 选船 (修理时间最长优先) + BathRoom 状态机 occupy + +说明: repair_one_available 在无空闲槽时直接跳过 (不进页面), 属正常路径; + 返回 False 不算失败, 终态判定只看是否回到主页面。 + +用法:: + + python tools/e2e/run.py bath_repair +""" + +from __future__ import annotations + + +DESC = '浴场修理链路: 首页 → 浴场 → 派修 → 回首页' + + +def run(rt) -> bool: # noqa: ANN001 + """执行浴场修理链路并验证终态契约。""" + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.business.logistics.repair.bath_repair import repair_one_available + + ctx = rt.ctx + + # ① 主体: 调度入口版本的浴场修理 (状态机判断 + 循环派修 + 回主页) + result = rt.action('执行 repair_one_available', repair_one_available, ctx) + if result is rt.FAILED: + return False + rt.note(f'派修结果: {result} (False = 无空槽/无船可修, 属正常跳过)') + + # ② 终态契约验证: 回到主页面 + rt.check('终态: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.note(f'当前页面: {identify_current_page(ctx)}') + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/campaign.py b/tools/debug_toolkit/function/e2e_runner/cases/campaign.py new file mode 100644 index 00000000..b82e2c65 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/campaign.py @@ -0,0 +1,122 @@ +"""战役断点与编队 E2E。""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '战役: 编队、断点和任务插入验证 (需 --with-ocr)' + +_DEFAULT_YAML = str( + Path(__file__).resolve().parents[5] / 'testing' / 'fixtures' / 'campaign_simple_destroyer.yaml' +) +_BREAKPOINTS = ( + 'panel_ready', + 'formation_entered', + 'fleet_checkpoint', + 'before_start', + 'battle_done', +) +_CHECK_TYPES = ('expedition_check', 'reward_check') + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义战役 case 参数。""" + parser.add_argument('--yaml', default=_DEFAULT_YAML, help='战役任务 YAML 路径') + parser.add_argument('--times', type=int, default=1, help='执行战役次数') + parser.add_argument('--pause-at', choices=_BREAKPOINTS, default=None, help='插入检查的断点') + parser.add_argument( + '--check', + choices=_CHECK_TYPES, + default='expedition_check', + help='断点插入的检查任务', + ) + parser.add_argument( + '--ship-name-alias', + action='append', + default=[], + metavar='CUSTOM=STANDARD', + help='用户舰名映射,可重复传入', + ) + + +def _aliases(ctx: Any, values: list[str]) -> dict[str, str] | None: + """合并配置和命令行舰名映射。""" + ocr_config = getattr(getattr(ctx, 'config', None), 'ocr', None) + result = dict(getattr(ocr_config, 'ship_name_aliases', {}) or {}) + for value in values: + alias, separator, standard = value.partition('=') + if not separator or not alias.strip() or not standard.strip(): + return None + result[alias.strip()] = standard.strip() + return result + + +def run(rt: Any) -> bool: + """执行一次战役任务并验证断点交接。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch import Processor, Request + + args = rt.args + if not 1 <= args.times <= 8: + rt.note('times 必须在 1-8 范围内') + return False + aliases = _aliases(rt.ctx, args.ship_name_alias) + if aliases is None: + rt.note('无效舰名映射') + return False + + request = rt.action( + '加载战役任务 YAML', + Request.from_yaml, + args.yaml, + source='cli', + count=args.times, + ship_name_aliases=aliases, + ) + if request is rt.FAILED: + return False + + processor = Processor(rt.ctx) + events: list[str] = [] + paused_page: list[str | None] = [] + interrupted = [False] + + def on_event(event: str, **_data: Any) -> None: + events.append(event) + if event == 'paused': + paused_page.append(identify_current_page(rt.ctx)) + if args.pause_at == event and not interrupted[0]: + interrupted[0] = True + processor.interrupt(Request(task_type=args.check, source='dependency')) + rt.note(f'断点 [{event}] 插入 {args.check}') + + processor.on_event = on_event + processor.submit(request) + outcomes = rt.action('执行战役任务', processor.run_pending) + if outcomes is rt.FAILED: + return False + + campaign_done = [ + result + for status, item, result in outcomes + if status == 'done' and item.task_type == 'campaign' + ] + check_done = [ + item.task_type + for status, item, _result in outcomes + if status == 'done' and item.task_type in _CHECK_TYPES + ] + rt.note(f'事件流水: {events}') + rt.check('战役完成次数一致', lambda: len(campaign_done) == args.times) + if args.pause_at: + rt.check('目标断点已触发', lambda: interrupted[0]) + rt.check('打断后回到首页', lambda: paused_page == [PageName.MAIN.value]) + rt.check('插入检查已完成', lambda: check_done == [args.check]) + rt.check('终态回到首页', lambda: identify_current_page(rt.ctx) == PageName.MAIN.value) + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/decisive.py b/tools/debug_toolkit/function/e2e_runner/cases/decisive.py new file mode 100644 index 00000000..ac52d62a --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/decisive.py @@ -0,0 +1,282 @@ +"""决战 E2E — 复用当前配置执行指定轮数的完整决战流程。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '决战: 使用 usersettings.yaml 配置执行完整决战流程 (需 --with-ocr)' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义决战 case 参数。""" + parser.add_argument( + '--times', + type=int, + default=None, + help='覆盖配置中的决战轮数 (默认使用 decisive_battle.decisive_rounds)', + ) + parser.add_argument( + '--scenario', + choices=('full', 'recovery-chain'), + default='full', + help='选择完整决战或四段恢复链路场景', + ) + + +def _run_recovery_chain( # noqa: C901, PLR0911, PLR0912, PLR0915 + rt: Any, + config: Any, +) -> bool: + """Run the four-stage real-device recovery chain without starting combat.""" + from autowsgr.ops import DecisiveController + from autowsgr.types import DecisivePhase + from autowsgr.ui.battle.preparation import BattlePreparationPage + from autowsgr.ui.decisive.preparation import DecisiveBattlePreparationPage + + controller = DecisiveController(rt.ctx, config) + controller._resume_mode = True + controller._has_chosen_fleet = False + + def wait_for_phase( + label: str, + expected: set[DecisivePhase], + ) -> DecisivePhase | None: + def wait_until_phase() -> DecisivePhase: + while controller.state.phase is DecisivePhase.WAITING_FOR_MAP: + controller._handle_waiting_for_map() + return controller.state.phase + + phase = rt.action(label, wait_until_phase) + if phase is rt.FAILED: + return None + rt.note(f'{label}: {phase.name}') + if not rt.check( + f'{label}状态正确', + lambda: controller.state.phase in expected, + ): + return None + return phase + + def wait_for_advance_choice(label: str) -> bool: + phase = wait_for_phase( + label, + {DecisivePhase.USE_LAST_FLEET, DecisivePhase.ADVANCE_CHOICE}, + ) + if phase is None: + return False + if phase is DecisivePhase.USE_LAST_FLEET: + if ( + rt.action( + f'{label}: 识别后选择上次舰队', + controller._handle_use_last_fleet, + ) + is rt.FAILED + ): + return False + phase = wait_for_phase( + f'{label}: 确认后等待前进点选择', + {DecisivePhase.ADVANCE_CHOICE}, + ) + return phase is DecisivePhase.ADVANCE_CHOICE + + def enter_map(label: str) -> bool: + controller._state.phase = DecisivePhase.ENTER_MAP + return rt.action(label, controller._handle_enter_map) is not rt.FAILED + + def reset_after_retreat() -> None: + controller._state.reset() + controller._state.phase = DecisivePhase.ENTER_MAP + + if rt.action('定位决战总览页', controller._prepare_entry_state) is rt.FAILED: + return False + reset_ok = rt.action('Case 1: 重置第六章状态', controller._battle_page.reset_chapter) + if reset_ok is rt.FAILED or not rt.check('Case 1: 第六章重置成功', lambda: bool(reset_ok)): + return False + + # Case 1: first entry -> advance choice -> normal fleet acquisition -> retreat. + if not enter_map('Case 1: 进入第一小关'): + return False + if not wait_for_advance_choice('Case 1: 等待前进点选择'): + return False + if rt.action('Case 1: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + if not wait_for_phase( + 'Case 1: 等待后续状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 1: 正常选择舰队', controller._handle_choose_fleet) is rt.FAILED + ): + return False + if not rt.check( + 'Case 1: 选船后处于准备链路', + lambda: controller.state.phase is DecisivePhase.PREPARE_COMBAT, + ): + return False + if rt.action('Case 1: 不进入战斗直接撤退', controller._execute_retreat) is rt.FAILED: + return False + reset_after_retreat() + + # Case 2: retreat re-entry -> advance choice -> mocked insufficient fleet -> retreat. + if not enter_map('Case 2: 撤退后重新进入'): + return False + if not wait_for_advance_choice('Case 2: 等待前进点选择'): + return False + if rt.action('Case 2: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + + original_best_fleet = controller._logic.get_best_fleet + original_recognize_node = controller._map.recognize_node + original_is_skill_used = controller._map.is_skill_used + + def mock_choose_one_fleet() -> None: + """Click the first real card, then leave only one ship for retreat logic.""" + controller._map.buy_fleet_option((0.25, 0.5)) + controller._state.ships.add(config.level1[0]) + controller._has_chosen_fleet = True + controller._state.phase = DecisivePhase.PREPARE_COMBAT + if not controller._map.close_fleet_overlay(): + raise RuntimeError('mock 购买第一艘舰船后无法关闭战备选择页') + + controller._logic.get_best_fleet = lambda: ['', config.level1[0], '', '', '', '', ''] + controller._map.recognize_node = lambda: 'A' + controller._map.is_skill_used = lambda: True + try: + if not wait_for_phase( + 'Case 2: 等待无船状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 2: mock 跳过识别并选择第一艘', mock_choose_one_fleet) is rt.FAILED + ): + return False + if not rt.check( + 'Case 2: mock 已实际选择一艘舰船', + lambda: config.level1[0] in controller.state.ships, + ): + return False + if rt.action('Case 2: mock 舰船不足判断', controller._handle_prepare_combat) is rt.FAILED: + return False + if not rt.check( + 'Case 2: 舰船不足触发撤退', + lambda: controller.state.phase is DecisivePhase.RETREAT, + ): + return False + finally: + controller._logic.get_best_fleet = original_best_fleet + controller._map.recognize_node = original_recognize_node + controller._map.is_skill_used = original_is_skill_used + + if rt.action('Case 2: 执行撤退', controller._execute_retreat) is rt.FAILED: + return False + reset_after_retreat() + + # Case 3: second retreat re-entry -> advance choice -> normal formation -> leave. + if not enter_map('Case 3: 再次重新进入'): + return False + if not wait_for_advance_choice('Case 3: 等待前进点选择'): + return False + if rt.action('Case 3: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + if not wait_for_phase( + 'Case 3: 等待准备状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 3: 正常选择舰队', controller._handle_choose_fleet) is rt.FAILED + ): + return False + if rt.action('Case 3: 进入编队页', controller._map.enter_formation) is rt.FAILED: + return False + prep_page = DecisiveBattlePreparationPage(rt.ctx, config, rt.ctx.ocr) + formation_ships = sorted(controller.state.ships)[:6] + if not formation_ships: + rt.note('Case 3: 本轮未记录到已购买舰船') + return False + if ( + rt.action( + 'Case 3: 正常完成编队', + prep_page.change_fleet, + None, + formation_ships, + ) + is rt.FAILED + ): + return False + if rt.action('Case 3: 编队完成回到地图', prep_page.go_back) is rt.FAILED: + return False + if rt.action('Case 3: 暂离', controller._execute_leave) is rt.FAILED: + return False + + # Case 4: leave resume -> no advance choice -> preparation page only. + if rt.action('Case 4: 定位已选节点', controller._prepare_entry_state) is rt.FAILED: + return False + if not enter_map('Case 4: 恢复进入地图'): + return False + if not wait_for_phase( + 'Case 4: 识别恢复后的页面', + {DecisivePhase.PREPARE_COMBAT}, + ): + return False + if rt.action('Case 4: 进入编队页', controller._map.enter_formation) is rt.FAILED: + return False + final_screen = rt.ctx.ctrl.screenshot() + rt.check( + 'Case 4: 停在可出征准备页', + lambda: bool(BattlePreparationPage.is_current_page(final_screen)), + ) + rt.note('Case 4: 未调用 start_battle,验证结束') + return rt.state.failed == 0 + + +def run(rt: Any) -> bool: + """执行决战并验证每轮都有明确结果。""" + from autowsgr.ops import DecisiveController + from autowsgr.ops.decisive.controller import DecisiveResult + + config = rt.ctx.config.decisive_battle + if config is None: + rt.note('usersettings.yaml 未配置 decisive_battle') + return False + if rt.ctx.ocr is None: + rt.note('决战需要 OCR,请使用 --with-ocr') + return False + + times = rt.args.times if rt.args.times is not None else config.decisive_rounds + if times < 1: + rt.note('times 必须大于 0') + return False + + if rt.args.scenario == 'recovery-chain': + return _run_recovery_chain(rt, config) + + rt.note(f'章节: {config.chapter} 轮数: {times}') + rt.note(f'一级舰队: {config.level1}') + rt.note(f'二级舰队: {config.level2}') + + controller = DecisiveController(rt.ctx, config) + results = rt.action( + f'执行决战第 {config.chapter} 章 x{times}', + controller.run_for_times, + times, + ) + if results is rt.FAILED: + return False + + rt.note(f'决战结果: {[result.value for result in results]}') + rt.check('结果轮数一致', lambda: len(results) == times) + rt.check( + '没有 ERROR 结果', + lambda: all(result is not DecisiveResult.ERROR for result in results), + ) + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/exercise.py b/tools/debug_toolkit/function/e2e_runner/cases/exercise.py new file mode 100644 index 00000000..ffb99ab0 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/exercise.py @@ -0,0 +1,529 @@ +"""演习断点打断 E2E — 处理器暂停 + 后勤检查插入 + 重跑计数。 + +验证用户敲定的五个场景 (一次运行验证一个断点场景, 演习对手打完即无): + 1. --pause-at panel_ready 导航结束后暂停 → 插后勤检查 → 回主页 + 2. --pause-at formation_entered 进入编队后暂停 → 插后勤检查 → 回主页 + 3. --pause-at ship_selected 选船完成后暂停 → 插后勤检查 → 回主页 + 4. --pause-at before_start 出征前暂停 → 插后勤检查 → 回主页 + 5. --pause-at rival_done 战斗完成后暂停 (计数保留) → 插后勤检查 → 回主页 + 6. 不带 --pause-at 直接跑完并统计计数 + +多轮模式 (--rounds N): + 每个请求只挑战一个对手,默认按 2,3 队伍循环;前两轮连续执行, + 第二轮后插入一次后勤任务验证非连续导航。``--rounds 6`` 用于完整六轮验证。 + +接力模式 (--relay): + 同一趟演习里依次覆盖导航入口、进入编队、一次选船、出征前和战斗结束 + 五个交接断点,每次插入一次后勤检查并自动恢复; 配合 --with-init + 在演习前先跑初始化链路 (含每日浮层清理)。 + +核心语义 (2026-08 用户敲定): 一次提交 = 持续打到没有对手 (以「打一个」 +为基础单元循环); 每打完一个对手计数 +1; 中间被更高优先级任务打断时在断点 +暂停 → 高优任务执行 → 恢复后接着打剩余的 → 直到没有对手。 + +打断机制: 事件回调里收到目标事件 → processor.interrupt(后勤检查请求) +→ 演习执行器在最近的 _wait 断点回主页、抛 TaskPaused → 处理器清信号、 +重排队 → 后勤检查先跑 → 演习重跑 (已打对手变灰自动跳过, 计数保留)。 + +用法:: + + # 场景4: 打完一个对手后打断一次 + python tools/e2e/run.py --with-ocr exercise --pause-at rival_done + + # 场景5: 不打断, 跑完全部并统计 + python tools/e2e/run.py --with-ocr exercise + + # 接力: 初始化(清弹窗) → 五类断点各插一次后勤检查 → 打完 + python tools/e2e/run.py --with-ocr exercise --relay --with-init + + # 换计划 YAML + python tools/e2e/run.py --with-ocr exercise --yaml <路径> + + # 六轮演习: 五个断点 + 队伍 2/3 切换 + 连续/非连续导航 + python tools/e2e/run.py --with-ocr --fast-ocr exercise --rounds 6 \ + --fleet-sequence 2,3 --continuous-rounds 2 --relay --with-init \ + --checks expedition_check,reward_check,expedition_check,reward_check,expedition_check +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '演习: 断点/多轮队伍切换/任务衔接验证 (需 --with-ocr)' + +# 默认计划: GUI 系统预设的队伍2演习 (用户提供的驱动 YAML) +_DEFAULT_YAML = str( + Path(__file__).resolve().parents[5] / 'testing' / 'fixtures' / 'exercise_team2.yaml' +) + +# 可单独验证的断点事件;旧的 rival_confirmed/fleet_ready 继续保留兼容。 +_BREAKPOINTS = ( + 'panel_ready', + 'rival_confirmed', + 'formation_entered', + 'fleet_ready', + 'ship_selected', + 'before_start', + 'rival_done', +) + +# 接力模式覆盖用户要求的五类交接节点;每类只在第一次上报时触发。 +_RELAY_PAUSES = ( + 'panel_ready', + 'formation_entered', + 'ship_selected', + 'before_start', + 'rival_done', +) + +_CHECK_TYPES = ('expedition_check', 'reward_check') + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument('--yaml', default=_DEFAULT_YAML, help='演习计划 YAML 路径') + parser.add_argument('--fleet-id', type=int, default=None, help='仅本次 E2E 覆盖舰队编号') + parser.add_argument( + '--ship-name-alias', + action='append', + default=[], + metavar='CUSTOM=STANDARD', + help='用户舰名映射,可重复传入', + ) + parser.add_argument( + '--rivals-limit', + type=int, + default=None, + help='仅本次 E2E 限制挑战对手数量,便于分次验证', + ) + parser.add_argument( + '--pause-at', + choices=_BREAKPOINTS, + default=None, + help='在哪个断点触发处理器打断 (不指定 = 不打断直接跑完)', + ) + parser.add_argument( + '--relay', + action='store_true', + help='接力模式: 五类交接断点依次各打断一次', + ) + parser.add_argument( + '--with-init', + action='store_true', + help='演习前先跑初始化链路 (任意状态 → 首页 + 每日浮层清理)', + ) + parser.add_argument( + '--check', + choices=('expedition_check', 'reward_check'), + default='expedition_check', + help='断点插入的后勤检查任务 (默认: expedition_check)', + ) + parser.add_argument( + '--checks', + default=None, + help='接力模式按断点顺序插入的检查任务, 逗号分隔 (共 5 项)', + ) + parser.add_argument('--rounds', type=int, default=1, help='按单场请求执行的演习轮数 (最多 6)') + parser.add_argument( + '--fleet-sequence', + default='2,3', + help='多轮模式循环使用的队伍编号,例如 2,3', + ) + parser.add_argument( + '--continuous-rounds', + type=int, + default=2, + help='多轮模式中连续任务阶段的轮数', + ) + + +def _resolve_checks(args: Any) -> tuple[str, ...]: + """解析断点检查序列; 未指定序列时保留旧的单检查行为。""" + raw = getattr(args, 'checks', None) + if raw is None: + checks = (args.check,) * len(_RELAY_PAUSES) if args.relay else (args.check,) + else: + checks = tuple(item.strip() for item in raw.split(',') if item.strip()) + if any(check not in _CHECK_TYPES for check in checks): + raise ValueError(f'检查任务必须属于: {", ".join(_CHECK_TYPES)}') + expected = len(_RELAY_PAUSES) if args.relay else 1 + if len(checks) != expected: + raise ValueError(f'当前模式需要 {expected} 个检查任务, 收到 {len(checks)} 个') + return checks + + +def _resolve_rounds(args: Any) -> tuple[int, tuple[int, ...], int]: + """校验多轮演习的次数、队伍循环和连续阶段边界。""" + rounds = int(getattr(args, 'rounds', 1)) + if not 1 <= rounds <= 6: + raise ValueError('rounds 必须在 1-6 范围内') + sequence = tuple( + int(value.strip()) + for value in str(getattr(args, 'fleet_sequence', '2,3')).split(',') + if value.strip() + ) + if not sequence or any(fleet_id not in range(1, 5) for fleet_id in sequence): + raise ValueError('fleet-sequence 必须是 1-4 的队伍编号列表') + continuous = int(getattr(args, 'continuous_rounds', 2)) + if not 1 <= continuous < rounds: + raise ValueError('continuous-rounds 必须小于 rounds 且至少为 1') + return rounds, sequence, continuous + + +def _collect_aliases(ctx: Any, args: Any) -> dict[str, str] | None: + """合并用户配置和命令行舰名映射。""" + ocr_config = getattr(getattr(ctx, 'config', None), 'ocr', None) + aliases = dict(getattr(ocr_config, 'ship_name_aliases', {}) or {}) + for value in args.ship_name_alias: + alias, separator, standard = value.partition('=') + if not separator or not alias.strip() or not standard.strip(): + return None + aliases[alias.strip()] = standard.strip() + return aliases + + +def _run_multiple_rounds( # noqa: PLR0915 - one E2E case owns the full scenario assertions + rt: Any, + ctx: Any, + args: Any, + aliases: dict[str, str], + checks: tuple[str, ...], +) -> bool: + """提交多个单场请求,验证队伍切换和连续/非连续任务衔接。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + rounds, fleet_sequence, continuous_rounds = _resolve_rounds(args) + processor = Processor(ctx) + requests: list[Request] = [] + request_index: dict[str, int] = {} + events: list[tuple[str, dict[str, Any]]] = [] + snapshots: dict[str, Any] = { + 'paused_pages': [], + 'paused_rounds': [], + 'round_start_pages': [], + 'noncontinuous_check': False, + } + + for round_number in range(1, rounds + 1): + fleet_id = fleet_sequence[(round_number - 1) % len(fleet_sequence)] + request = rt.action( + f'加载第 {round_number} 轮演习 YAML(队伍 {fleet_id})', + Request.from_yaml, + args.yaml, + source='cli', + ship_name_aliases=aliases, + ) + if request is rt.FAILED: + return False + request = replace( + request, + params={ + **request.params, + 'fleet_id': fleet_id, + 'rivals_limit': 1, + }, + ) + requests.append(request) + request_index[request.request_id] = round_number + + relay_index = [0] + relay_fired = [False] * len(_RELAY_PAUSES) + scheduled_rounds = {1} + + def interrupt_now(event: str, check: str) -> None: + rt.note(f'>> 断点 [{event}] 触发后勤任务: {check}') + processor.interrupt(Request(task_type=check, source='dependency')) + + def on_event(event: str, **data: Any) -> None: + events.append((event, dict(data))) + round_number = request_index.get(str(data.get('task_id', ''))) + if event == 'running' and data.get('task_type') == 'exercise': + page = identify_current_page(ctx) + snapshots['round_start_pages'].append((round_number, page)) + if event == 'paused': + page = identify_current_page(ctx) + snapshots['paused_pages'].append(page) + snapshots['paused_rounds'].append(round_number) + if args.relay: + index = relay_index[0] + if ( + index < len(_RELAY_PAUSES) + and event == _RELAY_PAUSES[index] + and not relay_fired[index] + ): + relay_fired[index] = True + relay_index[0] = index + 1 + interrupt_now(event, checks[index]) + if ( + event == 'completed' + and data.get('task_type') == 'exercise' + and round_number is not None + and round_number < rounds + and round_number + 1 not in scheduled_rounds + ): + if round_number == continuous_rounds and not snapshots['noncontinuous_check']: + snapshots['noncontinuous_check'] = True + processor.submit(Request(task_type=args.check, source='dependency')) + rt.note('>> 连续任务阶段结束,插入后勤任务验证非连续导航') + processor.submit(requests[round_number]) + scheduled_rounds.add(round_number + 1) + + processor.on_event = on_event + processor.submit(requests[0]) + outcomes = rt.action(f'执行{rounds}轮单场演习任务', processor.run_pending) + if outcomes is rt.FAILED: + return False + + done_exercises = [ + (request, result) + for status, request, result in outcomes + if status == 'done' and request.task_type == 'exercise' + ] + done_types = [request.task_type for status, request, _ in outcomes if status == 'done'] + expected_fleets = [fleet_sequence[i % len(fleet_sequence)] for i in range(rounds)] + actual_fleets = [request.params.get('fleet_id') for request, _ in done_exercises] + + rt.note(f'{rounds}轮事件流水: {[event for event, _ in events]}') + rt.note(f'任务完成流水: {done_types}') + rt.check(f'{rounds}轮演习全部完成', lambda: len(done_exercises) == rounds) + rt.check( + '每轮只挑战一个对手', + lambda: all(isinstance(result, list) and len(result) == 1 for _, result in done_exercises), + ) + rt.check('队伍按 2→3 循环切换', lambda: actual_fleets == expected_fleets) + rt.check( + '连续任务阶段导航从首页开始', + lambda: all(page == PageName.MAIN for _, page in snapshots['round_start_pages']), + ) + rt.check( + '连续任务存在相邻演习请求', + lambda: any( + done_types[i : i + 2] == ['exercise', 'exercise'] for i in range(len(done_types) - 1) + ), + ) + rt.check( + '非连续任务经过后勤检查', + lambda: ( + snapshots['noncontinuous_check'] + and any( + done_types[i] == 'exercise' + and done_types[i + 1] in _CHECK_TYPES + and done_types[i + 2] == 'exercise' + for i in range(len(done_types) - 2) + ) + ), + ) + if args.relay: + rt.check('五个断点全部触发', lambda: all(relay_fired)) + rt.check( + '断点暂停均回到首页', + lambda: ( + len(snapshots['paused_pages']) >= len(_RELAY_PAUSES) + and all(page == PageName.MAIN for page in snapshots['paused_pages']) + ), + ) + completed_checks = [ + request.task_type + for status, request, _ in outcomes + if status == 'done' and request.task_type in _CHECK_TYPES + ] + rt.check( + '断点后勤检查按顺序执行', + lambda: completed_checks[: len(checks)] == list(checks), + ) + rt.check(f'{rounds}轮任务最终回到首页', lambda: identify_current_page(ctx) == PageName.MAIN) + return rt.state.failed == 0 + + +def run(rt: Any) -> bool: # noqa: C901, PLR0912, PLR0915 - one E2E case owns its assertions + """执行演习断点打断验证 (单断点 / 接力 / 可带初始化前置)。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + args = rt.args + ctx = rt.ctx + checks = _resolve_checks(args) + rounds = int(getattr(args, 'rounds', 1)) + rt.note(f'计划: {args.yaml}') + if args.relay: + rt.note(f'模式: 接力 ({">".join(_RELAY_PAUSES)})') + else: + rt.note(f'打断点: {args.pause_at or "(不打断, 场景5)"}') + rt.note(f'插入检查: {checks}') + + # ── 阶段零 (可选): 初始化链路 + 每日浮层清理 ──────────────── + if args.with_init: + from autowsgr.application.runtime.initialize.initialize import initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + + if rt.action('初始化 (任意状态 → 首页 + 清浮层)', initialize, ctx) is rt.FAILED: + return False + rt.check('初始化后: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check( + '初始化后: 无浮层残留', + lambda: detect_overlay(ctx.ctrl.screenshot()) is None, + ) + + # ── 准备: 演习请求 (YAML 驱动) + 处理器 ───────────────────── + aliases = _collect_aliases(ctx, args) + if aliases is None: + rt.note('无效舰名映射') + return False + if rounds > 1: + return _run_multiple_rounds(rt, ctx, args, aliases, checks) + exercise_req = rt.action( + '加载演习计划 YAML', + Request.from_yaml, + args.yaml, + source='cli', + ship_name_aliases=aliases, + ) + if exercise_req is rt.FAILED: + return False + overrides: dict[str, Any] = {} + if args.fleet_id is not None: + overrides['fleet_id'] = args.fleet_id + if args.rivals_limit is not None: + overrides['rivals_limit'] = args.rivals_limit + if overrides: + exercise_req = replace( + exercise_req, + params={**exercise_req.params, **overrides}, + ) + rt.note(f'task_type={exercise_req.task_type} params={exercise_req.params} → 持续打到没有对手') + + processor = Processor(ctx) + events: list[tuple[str, dict]] = [] # 事件流水 (供断言与人工核对) + snapshots: dict[str, Any] = {} # 关键时刻的状态快照 + # 接力模式: 当前断点队列下标 + 各断点是否已触发 (重跑会重复上报事件) + relay_index = [0] + relay_fired: list[bool] = [False] * len(_RELAY_PAUSES) + + def interrupt_now(event: str, check: str) -> None: + """在断点插入后勤检查 (处理器加急)。""" + rt.note(f'>> 断点 [{event}] 触发处理器加急: 插入 {check}') + processor.interrupt(Request(task_type=check, source='dependency')) + + def on_event(event: str, **data: Any) -> None: + events.append((event, dict(data))) + if event == 'paused': + # paused 上报发生在回主页之后 → 立即验证锚点铁律 + page = identify_current_page(ctx) + fought = exercise_req.progress.get('fought', 0) + if args.relay: + snapshots.setdefault('paused_pages', []).append(page) + snapshots.setdefault('paused_fought', []).append(fought) + else: + snapshots['paused_page'] = page + snapshots['paused_fought'] = fought + if args.relay: + # 依次消费断点队列: 每个断点只在第一次上报时触发 + idx = relay_index[0] + if idx < len(_RELAY_PAUSES) and event == _RELAY_PAUSES[idx] and not relay_fired[idx]: + relay_fired[idx] = True + relay_index[0] = idx + 1 + interrupt_now(event, checks[idx]) + elif event == args.pause_at and not snapshots.get('interrupted'): + # 到达目标断点 → 模拟下游依赖加急插入后勤检查 + snapshots['interrupted'] = True + interrupt_now(event, checks[0]) + + processor.on_event = on_event + + # ── 阶段一: 首次提交 (可能被断点打断后恢复) ────────────────── + processor.submit(exercise_req) + outcomes = rt.action('首次提交 (演习 + 可能的打断恢复)', processor.run_pending) + if outcomes is rt.FAILED: + return False + + status_flow = [(status, req.task_type) for status, req, _ in outcomes] + rt.note(f'执行流: {status_flow}') + rt.note(f'事件流水: {[e for e, _ in events]}') + + if args.relay: + # 接力模式: 演习暂停 x5 → 后勤检查 x5 → 演习重跑完成 + expected_flow: list[tuple[str, str]] = [] + for check in checks: + expected_flow += [('paused', 'exercise'), ('done', check)] + expected_flow.append(('done', 'exercise')) + rt.check('执行流 = (暂停→后勤检查) x5 → 重跑完成', lambda: status_flow == expected_flow) + rt.check('五个交接断点全部触发过', lambda: all(relay_fired)) + paused_pages = snapshots.get('paused_pages', []) + rt.check( + '每次打断都在主页面 (锚点铁律)', + lambda: ( + len(paused_pages) == len(_RELAY_PAUSES) + and all(page == PageName.MAIN for page in paused_pages) + ), + ) + elif args.pause_at: + # 打断场景: 演习暂停 → 后勤检查先跑 → 演习重跑完成 + rt.check( + '执行流 = 暂停 → 后勤检查 → 重跑完成', + lambda: ( + status_flow == [('paused', 'exercise'), ('done', checks[0]), ('done', 'exercise')] + ), + ) + rt.check( + '打断时已回到主页面 (锚点铁律)', lambda: snapshots.get('paused_page') == PageName.MAIN + ) + rt.check(f'打断点 [{args.pause_at}] 确实触发过', lambda: bool(snapshots.get('interrupted'))) + if args.pause_at == 'rival_done': + # 战斗完成后打断: 计数已保留 (打过的那一场不丢) + rt.check( + '打断时计数已保留 (fought >= 1)', lambda: snapshots.get('paused_fought', 0) >= 1 + ) + else: + # 场景5: 不打断, 一趟完成 + rt.check('执行流 = 单趟完成', lambda: status_flow == [('done', 'exercise')]) + + # ── 计数统计: 一次提交打光全部 (rival_done 逐场累计) ───────── + done_results = next( + (r for s, req, r in outcomes if s == 'done' and req.task_type == 'exercise'), + [], + ) + total = exercise_req.progress.get('fought', 0) + # 最后一次打断时的保留计数 (接力 = 打一场后; 单断点 = 该断点时刻; 无打断 = 0) + paused_fought = ( + snapshots.get('paused_fought', [0])[-1] if args.relay else snapshots.get('paused_fought', 0) + ) + rt.note( + f'计数统计: 本任务共打 {total} 场 ' + f'(打断时已保留 {paused_fought} 场, 重跑补打 {len(done_results)} 场)', + ) + if total == 0: + # 无可挑战对手 (今日该时段已打完) — 空完成本身是正确行为, 不算失败 + rt.note('无可挑战对手 (今日该时段已打完), 空完成收尾属正常') + else: + rt.check( + '每场战斗都有 rival_done 计数 (一场一计)', + lambda: len(done_results) == total - paused_fought, + ) + rt.check('累计计数 = 暂停保留 + 重跑场数', lambda: total == paused_fought + len(done_results)) + + # 后勤检查执行过 (打断场景) 且终态回主页 + if args.relay or args.pause_at: + rt.check( + '后勤检查按断点顺序执行', + lambda: ( + [ + req.task_type + for status, req, _ in outcomes + if status == 'done' and req.task_type in _CHECK_TYPES + ] + == list(checks) + ), + ) + rt.check('终态: 主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/initialize.py b/tools/debug_toolkit/function/e2e_runner/cases/initialize.py new file mode 100644 index 00000000..c4aacd95 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/initialize.py @@ -0,0 +1,55 @@ +"""初始化链路 E2E — application.runtime.initialize 实机验证。 + +验证终态契约: 任意状态 → [首页 + 浮层已清 + 待机]。 +配合 ``--no-launch`` 使用 (跳过框架自己的 ensure_ready, 让 initialize 全权处理): +- 默认: 游戏保持当前状态, 验证分支 1/2 (已在首页 / 游戏内导航回首页) +- ``--cold``: 先强杀游戏, 验证完整冷启动分支 3 (启动 → 进入 → 首页 → 清浮层) + +用法:: + + python tools/e2e/run.py --no-launch initialize + python tools/e2e/run.py --no-launch initialize --cold +""" + +from __future__ import annotations + +import time + + +DESC = '初始化链路: 任意状态 → 首页待机 (SL 兜底)' + + +def add_arguments(parser) -> None: # noqa: ANN001 + """case 专属参数。""" + parser.add_argument('--cold', action='store_true', help='先强杀游戏再初始化 (测冷启动分支)') + + +def run(rt) -> bool: # noqa: ANN001 + """执行初始化链路并验证终态契约。""" + from autowsgr.application.runtime.initialize.initialize import _package_of, initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + from autowsgr.application.ui.navigation import identify_current_page + + ctx = rt.ctx + + # ① 初始状态记录 (对照用, 不参与判定) + start_page = rt.action('识别初始页面', identify_current_page, ctx) + if start_page is not rt.FAILED: + rt.note(f'初始页面: {start_page}') + + # ② --cold: 强杀游戏 → 强制走冷启动分支 + if rt.args.cold: + package = _package_of(ctx) + if rt.action(f'强杀游戏 ({package})', ctx.ctrl.stop_app, package) is rt.FAILED: + return False + time.sleep(2.0) + + # ③ 主体: initialize (内部含三分支判定 + SL 兜底) + if rt.action('执行 initialize', initialize, ctx) is rt.FAILED: + return False + + # ④ 终态契约验证: 首页 + 浮层已清 + rt.check('终态: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check('终态: 无浮层残留', lambda: detect_overlay(ctx.ctrl.screenshot()) is None) + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/map_navigation.py b/tools/debug_toolkit/function/e2e_runner/cases/map_navigation.py new file mode 100644 index 00000000..da6a494c --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/map_navigation.py @@ -0,0 +1,83 @@ +"""Map-only navigation stress case. + +Runs one shuffled set of chapters 1-9 and maps 1-4 for several rounds. It +never enters sortie preparation or starts combat; every target is verified by +the map title OCR after navigation. +""" + +from __future__ import annotations + +import random +import time +from typing import Any + + +DESC = '地图导航压力测试: 1-9章×1-4地图, 只导航不战斗' + + +def add_arguments(parser: Any) -> None: + """Define case-specific arguments.""" + parser.add_argument('--rounds', type=int, default=3, help='轮数 (默认 3)') + parser.add_argument('--seed', type=int, default=None, help='随机排列种子 (默认随机)') + + +def _navigate_target(page: Any, ctx: Any, chapter: int, map_num: int) -> None: + """Navigate to one map without entering sortie preparation.""" + reached = page.navigate_to_chapter(chapter) + if reached != chapter: + raise RuntimeError(f'章节导航结果错误: 目标={chapter}, 实际={reached}') + + page.navigate_to_map(map_num) + info = page.recognize_map(ctx.ctrl.screenshot(), ctx.ocr) + if info is None: + raise RuntimeError(f'地图标题 OCR 失败: 目标={chapter}-{map_num}') + if (info.chapter, info.map_num) != (chapter, map_num): + raise RuntimeError( + f'地图标题不匹配: 目标={chapter}-{map_num}, 实际={info.chapter}-{info.map_num}' + ) + + +def run(rt: Any) -> bool: + """Run three rounds of shuffled map-only navigation.""" + from autowsgr.ops.navigate import goto_page + from autowsgr.types import PageName + from autowsgr.ui.map.data import MapPanel + from autowsgr.ui.map.page import MapPage + + if rt.args.rounds <= 0: + raise ValueError('--rounds 必须大于 0') + + seed = rt.args.seed if rt.args.seed is not None else random.SystemRandom().randrange(1 << 31) + targets = [(chapter, map_num) for chapter in range(1, 10) for map_num in range(1, 5)] + random.Random(seed).shuffle(targets) + total = len(targets) * rt.args.rounds + rt.note(f'随机种子: {seed}; 每轮 {len(targets)} 个目标; 总计 {total} 次导航') + rt.note(f'顺序: {targets}') + + ctx = rt.ctx + if rt.action('进入地图页面', goto_page, ctx, PageName.MAP) is rt.FAILED: + return False + + page = MapPage(ctx) + if rt.action('切换到出征面板', page.ensure_panel, MapPanel.SORTIE) is rt.FAILED: + return False + time.sleep(1.5) + + completed = 0 + for round_index in range(1, rt.args.rounds + 1): + rt.note(f'开始第 {round_index}/{rt.args.rounds} 轮') + for index, (chapter, map_num) in enumerate(targets, 1): + label = f'第{round_index}轮 {index}/{len(targets)}: 导航 {chapter}-{map_num}' + result = rt.action( + label, + _navigate_target, + page, + ctx, + chapter, + map_num, + ) + if result is rt.FAILED: + return False + completed += 1 + + return rt.check('完成全部地图导航', lambda: completed == total) diff --git a/tools/debug_toolkit/function/e2e_runner/cases/normal_fight.py b/tools/debug_toolkit/function/e2e_runner/cases/normal_fight.py new file mode 100644 index 00000000..75cd1961 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/normal_fight.py @@ -0,0 +1,63 @@ +"""常规战快速验证 case — 接入现有 ops 操作的完整示例。 + +复用生产同款 ``run_normal_fight_from_yaml``, 用于快速实机验证: +计划加载 → 编队 → 多节点战斗 → 结算的常规战全链路。 +编队识别依赖 OCR, 请配 ``--with-ocr`` 运行。 + +用法:: + + # 默认跑 1 次 1-1 (内置计划) + python tools/e2e/run.py normal_fight --with-ocr + + # 指定计划与次数 + python tools/e2e/run.py normal_fight --with-ocr --plan 7-4千伪 --times 3 + + # 指定舰队编号 (默认用计划内配置) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --fleet 2 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '常规战: 跑 N 次指定作战计划 (需 --with-ocr)' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument('--plan', default='1-1', help='计划名或 YAML 路径 (默认 1-1)') + parser.add_argument('--times', type=int, default=1, help='执行次数 (默认 1)') + parser.add_argument('--fleet', type=int, default=None, help='舰队编号 (默认用计划配置)') + + +def run(rt: Any) -> bool: + """执行常规战验证步骤。""" + from autowsgr.business.combat.bettle import run_normal_fight_from_yaml + + args = rt.args + rt.note(f'计划: {args.plan} 次数: {args.times} 舰队: {args.fleet or "计划配置"}') + + # 步骤1: 执行常规战 (复用生产 ops, 内部含计划加载/编队/战斗/结算) + results = rt.action( + f'执行常规战 {args.plan} x{args.times}', + run_normal_fight_from_yaml, + rt.ctx, + args.plan, + times=args.times, + fleet_id=args.fleet, + ) + if results is rt.FAILED: + return False + + # 步骤2: 核对战斗次数 + ok = rt.check('战斗次数一致', lambda: len(results) == args.times) + + # 步骤3: 打印每场战果概况 (不计步, 供人工核对) + for i, r in enumerate(results, 1): + rt.note(f'第{i}场: flag={r.flag.name} 节点={r.node_count}') + + return ok diff --git a/tools/debug_toolkit/function/e2e_runner/cases/reward.py b/tools/debug_toolkit/function/e2e_runner/cases/reward.py new file mode 100644 index 00000000..036431b1 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/reward.py @@ -0,0 +1,99 @@ +"""奖励收取 E2E — reward_check 执行器 (处理器路径) + 兼容层 collect_rewards。 + +验证: + 1. 处理器路径: 提交 ``reward_check`` → 执行流 = [('done', 'reward_check')], + 事件流水含 ``checked``, 终态回主页 (锚点铁律)。 + 2. 兼容层路径: 直接调 ``collect_rewards(ctx)`` (auto_daily 定时器的调用方式), + 返回 bool, 调用后仍在主页。 + +无任务红点时执行器在主页空跑返回 (collected=0), 属正常安全行为; +断言只要求链路正确走完, 不要求本次一定收没收到奖励。 + +用法:: + + # 直接验证 (不初始化) + python tools/e2e/run.py reward + + # 先跑初始化链路 (任意状态 → 首页 + 每日浮层清理) + python tools/e2e/run.py reward --with-init +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '奖励收取: reward_check 执行器 + 兼容层 collect_rewards' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument( + '--with-init', + action='store_true', + help='验证前先跑初始化链路 (任意状态 → 首页 + 每日浮层清理)', + ) + + +def run(rt: Any) -> bool: + """执行奖励收取验证: 处理器路径 → 兼容层路径 → 锚点断言。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.business.logistics.reward import collect_rewards + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + ctx = rt.ctx + + # ── 阶段零 (可选): 初始化链路 + 每日浮层清理 ──────────────── + if rt.args.with_init: + from autowsgr.application.runtime.initialize.initialize import initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + + if rt.action('初始化 (任意状态 → 首页 + 清浮层)', initialize, ctx) is rt.FAILED: + return False + rt.check('初始化后: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check( + '初始化后: 无浮层残留', + lambda: detect_overlay(ctx.ctrl.screenshot()) is None, + ) + + # ── 阶段一: 处理器路径 (submit → run_pending) ──────────────── + req = Request(task_type='reward_check', source='cli') + events: list[str] = [] + + def on_event(event: str, **data: Any) -> None: # noqa: ARG001 + events.append(event) + + processor = Processor(ctx, on_event=on_event) + processor.submit(req) + outcomes = rt.action('提交 reward_check → 处理器执行', processor.run_pending) + if outcomes is rt.FAILED: + return False + + status_flow = [(status, r.task_type) for status, r, _ in outcomes] + rt.note(f'执行流: {status_flow}') + rt.note(f'事件流水: {events}') + + rt.check( + '执行流 = 单趟完成 (reward_check)', + lambda: status_flow == [('done', 'reward_check')], + ) + rt.check('上报了 checked 事件', lambda: 'checked' in events) + rt.check( + 'checked 事件只有一次 (短链路不重跑)', + lambda: events.count('checked') == 1, + ) + rt.check('终态: 主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + # ── 阶段二: 兼容层路径 (auto_daily 的调用方式) ─────────────── + result = rt.action('兼容层 collect_rewards(ctx)', collect_rewards, ctx) + if result is rt.FAILED: + return False + rt.check('collect_rewards 返回 bool', lambda: isinstance(result, bool)) + rt.check('兼容层调用后仍在主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + return rt.state.failed == 0 diff --git a/tools/debug_toolkit/function/e2e_runner/cases/screenshot.py b/tools/debug_toolkit/function/e2e_runner/cases/screenshot.py new file mode 100644 index 00000000..03aaf716 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/cases/screenshot.py @@ -0,0 +1,31 @@ +"""链路自检 case — 最小可用示例: ADB 连接 + 截图 + 页面识别。 + +不动游戏状态 (建议配 ``--no-launch``), 30 秒验证 "设备 → 截图 → 识别" 全链路。 +也是新 case 的最小模板: 复制本文件, 改 DESC 和 run() 即可。 +""" + +from __future__ import annotations + +from typing import Any + + +# 一句话描述 (--list 时显示) +DESC = '链路自检: 连接 + 截图 + 页面识别 (建议 --no-launch)' + + +def run(rt: Any) -> bool: + """执行链路自检步骤。""" + from autowsgr.ui.page import get_current_page + + # 步骤1: 截图 (失败时框架自动截图存证并返回 rt.FAILED) + screen = rt.action('截图', rt.ctx.ctrl.screenshot) + if screen is rt.FAILED: + return False + + # 步骤2: 识别当前页面 + page = rt.action('识别当前页面', get_current_page, screen) + if page is rt.FAILED: + return False + + rt.note(f'当前页面: {page}') + return True diff --git a/tools/debug_toolkit/function/e2e_runner/framework.py b/tools/debug_toolkit/function/e2e_runner/framework.py new file mode 100644 index 00000000..4a311ee7 --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/framework.py @@ -0,0 +1,371 @@ +"""E2E 快速实机验证框架 — 基座 (E2ERunner)。 + +算法说明: +1. 目标: 把 "连接设备 → 启动游戏 → 执行操作 → 判定结果" 的重复劳动收敛成一个 + 基座, 验证脚本 (case) 只写业务步骤本身, 实现 "加一个文件 = 多一个可跑的实机 + 验证"。 +2. 初始化: 走生产同款 Launcher 流程 (load_config → setup_logger → connect → + ensure_ready), 保证验证环境与 GUI / examples 脚本运行时行为一致; + --no-launch 可跳过游戏就绪 (纯只读验证), --with-ocr 决定是否初始化 OCR。 +3. 步骤执行: rt.action(label, fn) 执行任意函数并计时; 正常时原样返回 fn 的返回 + 值, 抛异常时记为失败、自动截图存证并返回 FAILED 哨兵, case 据此短路退出。 +4. 断言: rt.check(label, fn) 把返回值当 bool 判定, 用于次数 / 状态核对。 +5. 汇总: finalize(overall) 汇总所有步骤的通过 / 失败, 结合 case 整体返回值 + 给出进程退出码 (0 = 全部通过), 供终端与 CI 直接判断。 +""" + +from __future__ import annotations + +# 处理 Windows GBK 编码兼容性 (中文输出在默认代码页下可能乱码) +import sys +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + + +try: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') +except Exception: # noqa: S110 + pass # reconfigure 不可用时继续使用默认编码 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 数据结构 +# ═══════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class StepRec: + """单条步骤记录 (action / check 各记一条)。""" + + label: str # 步骤描述 (中文, 显示给用户) + ok: bool # 是否通过 (action: 是否抛异常; check: 断言结果) + duration_ms: int = 0 # 耗时 (毫秒) + error: str | None = None # 异常信息 (失败时) + + +@dataclass +class RunnerState: + """E2ERunner 的步骤累计状态。""" + + steps: list[StepRec] = field(default_factory=list) + + @property + def failed(self) -> int: + """失败步骤数。""" + return sum(1 for s in self.steps if not s.ok) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 基座 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class E2ERunner: + """单个 E2E case 的执行基座。 + + 职责: 设备连接、游戏就绪、步骤计时与记录、失败截图、汇总与退出码。 + case 只需调用 :meth:`action` / :meth:`check` / :meth:`note` 组织自己的步骤。 + """ + + # 失败哨兵: action 抛异常时返回它 (区别于业务返回值 None/False/空列表) + FAILED = object() + + def __init__( + self, + case_name: str, + case_args: Any, + *, + serial: str | None = None, + debug: bool = False, + no_launch: bool = False, + with_ocr: bool = False, + fast_ocr: bool = False, + ) -> None: + self.case_name = case_name + self.args = case_args # case 自己的参数 namespace (run.py 解析后传入) + self.serial = serial # ADB 序列号; None 时用 usersettings.yaml 配置 + self.debug = debug # True 时日志级别 DEBUG + self.no_launch = no_launch # True 时跳过游戏就绪 (纯只读验证) + self.with_ocr = with_ocr # True 时初始化 OCR 引擎 (编队识别等需要) + self.fast_ocr = fast_ocr # True 时仅在本次 E2E 内切换 CPU FastOCR + self.state = RunnerState() + self.ctx: Any = None # GameContext (prepare() 成功后可用) + self._launcher: Any = None + self._connected = False + self._cleanup_done = False + self._recovery_in_progress = False + # 每次运行独立目录: logs/e2e_tools//<时间戳> + stamp = datetime.now().astimezone().strftime('%Y%m%d_%H%M%S') + self.log_dir = Path('logs/e2e_tools') / case_name / stamp + + # ── 初始化 ───────────────────────────────────────────────────── + + def prepare(self) -> bool: + """连接设备并准备游戏环境。 + + 流程与生产 launch() 对齐: 读配置 → 初始化日志 → 连接设备 → 游戏就绪。 + ``no_launch=True`` 时只连接不启动游戏 (截图/页面识别等只读验证)。 + """ + from autowsgr.infra.logger import setup_logger + from autowsgr.scheduler.launcher import Launcher + + launcher = Launcher() + # Keep the handle before connect so a partially connected controller is + # still released when connection or context construction fails. + self._launcher = launcher + cfg = launcher.load_config() + + if self.fast_ocr: + ocr_cfg = cfg.ocr.model_copy(update={'enhanced_ship_ocr': True}) + launcher.set_config(cfg.model_copy(update={'ocr': ocr_cfg})) + + # 日志目录/级别以本次运行为准, 通道配置沿用 usersettings.yaml + channels = cfg.log.effective_channels or None + setup_logger( + log_dir=self.log_dir, + level='DEBUG' if self.debug else 'INFO', + save_images=True, + channels=channels, + ) + + # 命令行指定 serial 时覆盖配置 + if self.serial is not None: + emu = cfg.emulator.model_copy(update={'serial': self.serial}) + launcher.set_config(cfg.model_copy(update={'emulator': emu})) + + # 连接设备 + try: + launcher.connect() + self._connected = True + serial = launcher.config.emulator.serial or 'auto' + res = launcher.ctrl.resolution + print(f' [OK] 设备已连接: {serial} {res[0]}x{res[1]}') + except Exception as exc: + self._record('连接设备', ok=False, error=str(exc)) + print(f' [FAIL] 设备连接失败: {exc}') + return False + + try: + # 组装 GameContext (与 testing/ops 的 launch_for_test 同款流程) + from autowsgr.context import GameContext + + if self.with_ocr: + self.ctx = launcher.build_context() # 含 OCR 引擎 + else: + self.ctx = GameContext(ctrl=launcher.ctrl, config=launcher.config, ocr=None) + + if self.no_launch: + # 显式只读模式: 不启动/导航游戏, 供截图和页面识别诊断使用。 + return True + + # 所有正常 E2E 都从同一个业务初始化入口开始,避免 OCR 分支 + # 与普通分支产生不同的首页/浮层状态。 + self._initialize_game() + except Exception as exc: + self._record('初始化游戏', ok=False, error=str(exc)) + print(f' [FAIL] 游戏初始化失败: {exc}') + self._error_screenshot('prepare') + return False + return True + + # ── 步骤 API (case 调用) ─────────────────────────────────────── + + def action(self, label: str, fn: Any, /, *args: Any, **kwargs: Any) -> Any: + """执行一个操作步骤。 + + 正常返回 fn 的返回值; 抛异常时记录失败、自动截图、返回 ``rt.FAILED``。 + case 约定写法:: + + results = rt.action('跑常规战', run_normal_fight_from_yaml, ctx, '1-1') + if results is rt.FAILED: + return False + """ + t0 = time.monotonic() + try: + if not self._ensure_game_running(): + raise RuntimeError('游戏进程已退出且重新初始化失败') + value = fn(*args, **kwargs) + # 操作期间进程可能被外部关闭。此处只恢复运行环境,不重放 + # 已经执行过的业务函数,避免点击类操作被重复提交。 + if not self._ensure_game_running(): + raise RuntimeError('操作后游戏进程已退出且重新初始化失败') + except Exception as exc: + self._recover_after_error() + self._record(label, ok=False, error=str(exc), t0=t0) + print(f' [FAIL] {label}: {exc}') + self._error_screenshot(label) + return self.FAILED + self._record(label, ok=True, t0=t0) + print(f' [OK] {label} ({self._ms_since(t0)}ms)') + return value + + def check(self, label: str, fn: Any, /, *args: Any, **kwargs: Any) -> bool: + """断言步骤: 把 fn 返回值当 bool 判定, 打印 PASS/FAIL。""" + t0 = time.monotonic() + try: + if not self._ensure_game_running(): + raise RuntimeError('游戏进程已退出且重新初始化失败') + passed = bool(fn(*args, **kwargs)) + error: str | None = None + if not self._ensure_game_running(): + passed = False + error = '检查后游戏进程已退出且重新初始化失败' + except Exception as exc: + self._recover_after_error() + passed = False + error = str(exc) + self._record(label, ok=passed, error=error, t0=t0) + mark = 'PASS' if passed else 'FAIL' + suffix = f': {error}' if error else '' + print(f' [{mark}] {label}{suffix}') + return passed + + def note(self, msg: str) -> None: + """打印一条说明信息 (不计入步骤, 不影响判定)。""" + print(f' [i] {msg}') + + # ── 兜底与汇总 ───────────────────────────────────────────────── + + def unexpected(self, exc: Exception) -> None: + """case 主体自身抛出的未捕获异常兜底 (记为一条失败步骤)。""" + self._record(f'case 异常 ({type(exc).__name__})', ok=False, error=str(exc)) + print(f' [FAIL] case 异常: {exc}') + self._error_screenshot('case_crash') + + def finalize(self, *, overall: bool = True) -> int: + """先收口游戏生命周期,再打印汇总并返回进程退出码。""" + self.cleanup() + total = len(self.state.steps) + failed = self.state.failed + print() + print('═' * 68) + for i, s in enumerate(self.state.steps, 1): + mark = 'OK ' if s.ok else 'FAIL' + dur = f'{s.duration_ms}ms' if s.duration_ms else '' + err = f' ← {s.error}' if s.error else '' + print(f' [{mark}] [{i:02d}] {s.label} {dur}{err}') + verdict = 'PASS' if (failed == 0 and overall) else 'FAIL' + print() + print(f' {self.case_name}: {total} 步, 失败 {failed} 步 → {verdict}') + print(f' 日志目录: {self.log_dir.resolve()}') + print('═' * 68) + return 0 if verdict == 'PASS' else 1 + + def cleanup(self) -> None: + """无论 case 结果如何,尝试回主页并释放设备连接。""" + if self._cleanup_done: + return + self._cleanup_done = True + + if self.ctx is not None and not self.no_launch: + t0 = time.monotonic() + try: + self._initialize_game() + except Exception as exc: + self._record('清理: 回到主页面', ok=False, error=str(exc), t0=t0) + print(f' [FAIL] 清理: 回到主页面: {exc}') + self._error_screenshot('cleanup') + else: + self._record('清理: 回到主页面', ok=True, t0=t0) + print(f' [OK] 清理: 回到主页面 ({self._ms_since(t0)}ms)') + + if self._launcher is None or not self._connected: + return + try: + self._launcher.ctrl.disconnect() + self._connected = False + except Exception as exc: + self._record('清理: 断开设备', ok=False, error=str(exc)) + print(f' [FAIL] 清理: 断开设备: {exc}') + + # ── 游戏生命周期 ───────────────────────────────────────────── + + def _game_package(self) -> str: + """返回当前配置对应的 Android 包名。""" + if self.ctx is None: + raise RuntimeError('GameContext 尚未构造') + return self.ctx.config.account.package_name + + def _initialize_game(self) -> None: + """调用当前启动器的就绪入口,保证终态为首页待机。""" + if self.ctx is None: + raise RuntimeError('GameContext 尚未构造') + if self._launcher is None: + raise RuntimeError('Launcher 尚未构造') + self._launcher.ensure_ready(self.ctx) + + def _ensure_game_running(self) -> bool: + """检测游戏是否仍在运行;异常退出时只重新初始化一次。""" + if self.no_launch or self.ctx is None: + return True + try: + running = self.ctx.ctrl.is_app_running(self._game_package()) + except Exception as exc: + self.note(f'无法检查游戏进程: {exc}') + return False + if running or self._recovery_in_progress: + return running + + self._recovery_in_progress = True + try: + self.note('检测到游戏进程异常退出,重新初始化') + self._initialize_game() + except Exception as exc: + self.note(f'重新初始化失败: {exc}') + return False + else: + return True + finally: + self._recovery_in_progress = False + + def _recover_after_error(self) -> None: + """在步骤异常后尝试恢复已退出的游戏进程。""" + if self.no_launch or self.ctx is None or self._recovery_in_progress: + return + try: + self._ensure_game_running() + except Exception as recovery_error: + self.note(f'异常后恢复失败: {recovery_error}') + + # ── 内部工具 ─────────────────────────────────────────────────── + + @staticmethod + def _ms_since(t0: float) -> int: + return int((time.monotonic() - t0) * 1000) + + def _record( + self, + label: str, + *, + ok: bool, + error: str | None = None, + t0: float | None = None, + ) -> None: + self.state.steps.append( + StepRec( + label=label, + ok=ok, + duration_ms=self._ms_since(t0) if t0 is not None else 0, + error=error, + ) + ) + + def _error_screenshot(self, label: str) -> None: + """失败时自动截图存证 (截图本身失败则静默跳过)。""" + if self.ctx is None or self.ctx.ctrl is None: + return + try: + from autowsgr.infra import save_image + + screen = self.ctx.ctrl.screenshot() + tag = f'e2e_fail_{label.replace(" ", "_")[:40]}' + path = save_image(screen, tag=tag) + if path: + print(f' 失败截图: {path}') + except Exception: # noqa: S110 + pass diff --git a/tools/debug_toolkit/function/e2e_runner/run.py b/tools/debug_toolkit/function/e2e_runner/run.py new file mode 100644 index 00000000..e8feb2ea --- /dev/null +++ b/tools/debug_toolkit/function/e2e_runner/run.py @@ -0,0 +1,211 @@ +"""E2E 快速实机验证入口 — case 发现、参数路由、执行与退出码。 + +用法:: + + # 列出全部可用 case + python tools/e2e/run.py --list + + # 链路自检 (只连接 + 截图, 不动游戏状态) + python tools/e2e/run.py screenshot --no-launch + + # 跑一次常规战 (编队识别需要 OCR) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --times 1 + + # 指定设备与调试日志 + python tools/e2e/run.py --serial 127.0.0.1:16384 --debug screenshot + +参数顺序约定: 全局参数可放在 case 名之前或之后, case 参数跟随 case。 +全局参数: --serial SERIAL / --debug / --no-launch / --with-ocr / --fast-ocr / --list。 +其余参数由 case 自己的 add_arguments(parser) 定义并解析 (见 cases/ 示例)。 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from typing import Any + + +# 处理 Windows GBK 编码兼容性 +try: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') +except Exception: # noqa: S110 + pass + +# 保证仓库根可导入 (直接 python tools/e2e/run.py 运行时 sys.path[0] 是 tools/e2e) +_REPO_ROOT = Path.cwd() +if not (_REPO_ROOT / 'autowsgr').is_dir(): + _REPO_ROOT = Path(__file__).resolve().parents[5] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +_CASES_DIR = Path(__file__).parent / 'cases' + + +# ═══════════════════════════════════════════════════════════════════════════════ +# case 发现与加载 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def load_cases() -> dict[str, Any]: + """扫描 cases/ 目录, 加载所有带 run() 的验证脚本。 + + case 约定 (见 cases/ 内示例): + - 必须定义 ``run(rt) -> bool``: 步骤主体, 返回整体判定; + - 可选 ``DESC: str``: 一句话描述, --list 时显示; + - 可选 ``add_arguments(parser)``: 定义 case 专属命令行参数。 + """ + cases: dict[str, Any] = {} + if not _CASES_DIR.exists(): + return cases + for py in sorted(_CASES_DIR.glob('*.py')): + if py.name.startswith('_'): + continue + spec = importlib.util.spec_from_file_location( + f'tools.debug_toolkit.function.e2e_runner.cases.{py.stem}', + py, + ) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if hasattr(mod, 'run'): + cases[py.stem] = mod + return cases + + +def print_case_list(cases: dict[str, Any]) -> None: + """打印全部 case 及其描述。""" + print() + print('═' * 68) + print(' 可用 E2E 验证 case') + print('═' * 68) + if not cases: + print(f' (cases 目录为空: {_CASES_DIR})') + for name, mod in cases.items(): + desc = getattr(mod, 'DESC', '') + print(f' {name:24s} {desc}') + print() + print(' 运行: python tools/e2e/run.py [全局参数在前, case 参数在后]') + print(' 全局: --serial SERIAL / --debug / --no-launch / --with-ocr') + print('═' * 68) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 参数切分: [全局参数] [case参数] +# ═══════════════════════════════════════════════════════════════════════════════ + + +def split_argv(argv: list[str]) -> tuple[list[str], str | None, list[str]]: + """把 argv 切成 (全局参数, case 名, case 参数)。 + + 全局参数可出现在 case 名之前或之后; ``--serial`` 带一个值; + 第一个非 ``-`` 开头的 token 视为 case 名, 其余非全局 token 归 case 参数。 + """ + global_flags = {'--debug', '--fast-ocr', '--list', '--no-launch', '--with-ocr'} + global_args: list[str] = [] + case_name: str | None = None + case_args: list[str] = [] + i = 0 + while i < len(argv): + tok = argv[i] + if tok == '--serial' and i + 1 < len(argv): + global_args.extend(argv[i : i + 2]) + i += 2 # --serial 及其值 + continue + if tok in global_flags: + global_args.append(tok) + i += 1 + continue + if case_name is None and not tok.startswith('-'): + case_name = tok + elif case_name is None: + global_args.append(tok) + else: + case_args.append(tok) + i += 1 + return global_args, case_name, case_args + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 主流程 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def main() -> int: + global_argv, case_name, rest = split_argv(sys.argv[1:]) + + cases = load_cases() + + # --list 或未指定 case: 打印列表后退出 + if '--list' in global_argv or case_name is None: + print_case_list(cases) + return 0 + + mod = cases.get(case_name) + if mod is None: + print(f'未知 case: {case_name}') + print_case_list(cases) + return 2 + + # 全局参数 + gp = argparse.ArgumentParser(add_help=False) + gp.add_argument('--serial', default=None, help='ADB 设备序列号 (默认用配置)') + gp.add_argument('--debug', action='store_true', help='DEBUG 日志') + gp.add_argument('--no-launch', action='store_true', help='跳过游戏就绪 (只读验证)') + gp.add_argument('--with-ocr', action='store_true', help='初始化 OCR 引擎') + gp.add_argument('--fast-ocr', action='store_true', help='本次运行使用 CPU FastOCR') + g = gp.parse_args(global_argv) + + # case 参数 (由 case 自己定义; 未定义 add_arguments 时 rest 必须为空) + cp = argparse.ArgumentParser( + prog=f'e2e {case_name}', + description=getattr(mod, 'DESC', ''), + ) + if hasattr(mod, 'add_arguments'): + mod.add_arguments(cp) + case_args = cp.parse_args(rest) + + print() + print('═' * 68) + print(f' E2E: {case_name} — {getattr(mod, "DESC", "")}') + print('═' * 68) + print(f' 设备: {g.serial or "自动检测 (usersettings.yaml)"}') + print( + f' 模式: {"只读 (跳过游戏就绪)" if g.no_launch else "完整 (游戏就绪)"}' + f'{" + OCR" if g.with_ocr else ""}{" + FastOCR" if g.fast_ocr else ""}' + ) + + # 执行 + from tools.debug_toolkit.function.e2e_runner.framework import E2ERunner + + rt = E2ERunner( + case_name, + case_args, + serial=g.serial, + debug=g.debug, + no_launch=g.no_launch, + with_ocr=g.with_ocr, + fast_ocr=g.fast_ocr, + ) + if not rt.prepare(): + return rt.finalize(overall=False) + overall = False + try: + overall = bool(mod.run(rt)) + except SystemExit: + # finalize() 位于 finally,保证显式退出也执行回主页/断开连接。 + raise + except Exception as exc: + rt.unexpected(exc) + finally: + rt.finalize(overall=overall) + return 0 if overall and rt.state.failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/debug_toolkit/function/ocr.py b/tools/debug_toolkit/function/ocr.py new file mode 100644 index 00000000..783c81d8 --- /dev/null +++ b/tools/debug_toolkit/function/ocr.py @@ -0,0 +1,49 @@ +"""Run project OCR and write structured results under result/ocr.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import cv2 + +from autowsgr.vision import EasyOCREngine + + +if TYPE_CHECKING: + import argparse + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +RESULT_ROOT = PACKAGE_ROOT / 'result' / 'ocr' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('-i', '--image', type=Path, required=True) + parser.add_argument('--allowlist', default='') + parser.add_argument('-o', '--output-root', type=Path, default=RESULT_ROOT) + + +def run(args: argparse.Namespace) -> int: + image = cv2.imread(str(args.image)) + if image is None: + raise FileNotFoundError(args.image) + rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + engine = EasyOCREngine.create(gpu=False, mirror='modelscope') + results = engine.recognize(rgb, allowlist=args.allowlist or None) + payload = [ + { + 'text': result.text, + 'confidence': result.confidence, + 'bbox': result.bbox, + } + for result in results + ] + output = args.output_root / f'{args.image.stem}.json' + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8') + print(output) + for result in payload: + print(f'{result["text"]} ({result["confidence"]:.3f})') + return 0 diff --git a/tools/debug_toolkit/function/roi.py b/tools/debug_toolkit/function/roi.py new file mode 100644 index 00000000..3d5d0394 --- /dev/null +++ b/tools/debug_toolkit/function/roi.py @@ -0,0 +1,66 @@ +"""Crop one source screenshot into the standard 1x/2x/4x/8x layout.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 + +from tools.ocr_crop_tool import CropToolError, _write_png + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +RESULT_ROOT = PACKAGE_ROOT / 'result' / 'screenshot' +SCALES = (1, 2, 4, 8) + + +def _parse_roi(value: str) -> tuple[float, float, float, float]: + try: + values = tuple(float(item) for item in value.split(',')) + except ValueError as exc: + raise argparse.ArgumentTypeError('ROI must be x1,y1,x2,y2') from exc + if len(values) != 4 or not all(0.0 <= value <= 1.0 for value in values): + raise argparse.ArgumentTypeError('ROI values must be in [0, 1]') + x1, y1, x2, y2 = values + if x1 >= x2 or y1 >= y2: + raise argparse.ArgumentTypeError('ROI upper-left must precede lower-right') + return values + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('-i', '--image', type=Path, required=True) + parser.add_argument('--roi', type=_parse_roi, required=True, metavar='X1,Y1,X2,Y2') + parser.add_argument('-o', '--output-root', type=Path, default=RESULT_ROOT) + + +def run(args: argparse.Namespace) -> int: + image = cv2.imread(str(args.image)) + if image is None: + raise CropToolError(f'cannot read image: {args.image}') + height, width = image.shape[:2] + x1, y1, x2, y2 = args.roi + left, top = int(x1 * width), int(y1 * height) + right, bottom = int(x2 * width), int(y2 * height) + crop = image[top:bottom, left:right] + if crop.size == 0: + raise CropToolError('ROI produced an empty image') + + stem = args.image.stem + target = args.output_root / stem + _write_png(target / f'{stem}.png', image) + for scale in SCALES: + output = ( + crop + if scale == 1 + else cv2.resize( + crop, + None, + fx=scale, + fy=scale, + interpolation=cv2.INTER_CUBIC, + ) + ) + _write_png(target / f'{scale}x' / f'{stem}_roi_{scale}x.png', output) + print(target) + return 0 diff --git a/tools/debug_toolkit/function/screenshot.py b/tools/debug_toolkit/function/screenshot.py new file mode 100644 index 00000000..d8045db9 --- /dev/null +++ b/tools/debug_toolkit/function/screenshot.py @@ -0,0 +1,39 @@ +"""Save one raw ADB screenshot with a timestamped name.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING + +from tools.ocr_crop_tool import ( + _read_saved_serial, + _resolve_adb_path, + _write_png, + capture_adb_screen, +) + + +if TYPE_CHECKING: + import argparse + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +RESULT_ROOT = PACKAGE_ROOT / 'result' / 'screenshot' +ADB_PATH = PACKAGE_ROOT / 'adb' / 'adb.exe' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--serial', help='ADB serial, defaults to the saved serial') + parser.add_argument('--adb-path', type=Path, help='override the bundled adb.exe') + parser.add_argument('-o', '--output', type=Path, help='explicit PNG path') + + +def run(args: argparse.Namespace) -> int: + adb_path = args.adb_path or (ADB_PATH if ADB_PATH.is_file() else _resolve_adb_path()) + serial = args.serial or _read_saved_serial() + image = capture_adb_screen(adb_path, serial) + output = args.output or RESULT_ROOT / f'screenshot_{datetime.now(UTC):%Y%m%d_%H%M%S}.png' + _write_png(output, image) + print(output) + return 0 diff --git a/tools/debug_toolkit/main.py b/tools/debug_toolkit/main.py new file mode 100644 index 00000000..6f8a8391 --- /dev/null +++ b/tools/debug_toolkit/main.py @@ -0,0 +1,41 @@ +"""Agent-facing entrypoint for the debug toolkit.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +if __package__: + from .function import e2e, ocr, roi, screenshot +else: + from tools.debug_toolkit.function import e2e, ocr, roi, screenshot + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments and arguments[0] == 'e2e': + return e2e.run(arguments[1:]) + + parser = argparse.ArgumentParser(prog='debug_toolkit') + subparsers = parser.add_subparsers(dest='command', required=True) + screenshot.add_arguments(subparsers.add_parser('screenshot')) + roi.add_arguments(subparsers.add_parser('roi')) + ocr.add_arguments(subparsers.add_parser('ocr')) + parser.epilog = 'e2e: debug_toolkit e2e ' + args = parser.parse_args(arguments) + + if args.command == 'screenshot': + return screenshot.run(args) + if args.command == 'roi': + return roi.run(args) + return ocr.run(args) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/debug_toolkit/result/logs/.gitkeep b/tools/debug_toolkit/result/logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tools/debug_toolkit/result/ocr/.gitkeep b/tools/debug_toolkit/result/ocr/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tools/debug_toolkit/result/screenshot/.gitkeep b/tools/debug_toolkit/result/screenshot/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tools/e2e/__init__.py b/tools/e2e/__init__.py new file mode 100644 index 00000000..1fb955b2 --- /dev/null +++ b/tools/e2e/__init__.py @@ -0,0 +1,10 @@ +"""E2E 快速实机验证工具包 (tools/e2e)。 + +用法:: + + python tools/e2e/run.py --list # 列出全部验证 case + python tools/e2e/run.py screenshot --no-launch # 链路自检 (不动游戏) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --times 1 + +新增加速验证: 在 cases/ 目录复制 template 改写 run() 即可, 无需改框架。 +""" diff --git a/tools/e2e/cases/__init__.py b/tools/e2e/cases/__init__.py new file mode 100644 index 00000000..8b1e5795 --- /dev/null +++ b/tools/e2e/cases/__init__.py @@ -0,0 +1,9 @@ +"""E2E 验证 case 目录。 + +每个 *.py 是一个可独立运行的实机验证脚本, 约定: +- 必须: ``def run(rt) -> bool`` — 步骤主体 (rt 是 E2ERunner 基座) +- 可选: ``DESC: str`` — 一句话描述 (--list 显示) +- 可选: ``def add_arguments(parser)`` — case 专属命令行参数 + +新增验证: 复制任意示例文件改写 run() 即可, 框架自动发现。 +""" diff --git a/tools/e2e/cases/bath_repair.py b/tools/e2e/cases/bath_repair.py new file mode 100644 index 00000000..beaf49e6 --- /dev/null +++ b/tools/e2e/cases/bath_repair.py @@ -0,0 +1,40 @@ +"""浴场修理链路 E2E — business.logistics.repair 实机验证。 + +验证链路契约: 首页 → 浴场 → 选择修理 overlay → 派修 → 回首页。 +覆盖点: + - goto_page 跨页导航 (浴场页位于 application/ui/controller/bath_page/) + - 选择修理 overlay 开关机制 (点击舰船后自动关闭) + - OCR 选船 (修理时间最长优先) + BathRoom 状态机 occupy + +说明: repair_one_available 在无空闲槽时直接跳过 (不进页面), 属正常路径; + 返回 False 不算失败, 终态判定只看是否回到主页面。 + +用法:: + + python tools/e2e/run.py bath_repair +""" + +from __future__ import annotations + + +DESC = '浴场修理链路: 首页 → 浴场 → 派修 → 回首页' + + +def run(rt) -> bool: + """执行浴场修理链路并验证终态契约。""" + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.business.logistics.repair.bath_repair import repair_one_available + + ctx = rt.ctx + + # ① 主体: 调度入口版本的浴场修理 (状态机判断 + 循环派修 + 回主页) + result = rt.action('执行 repair_one_available', repair_one_available, ctx) + if result is rt.FAILED: + return False + rt.note(f'派修结果: {result} (False = 无空槽/无船可修, 属正常跳过)') + + # ② 终态契约验证: 回到主页面 + rt.check('终态: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.note(f'当前页面: {identify_current_page(ctx)}') + return rt.state.failed == 0 diff --git a/tools/e2e/cases/campaign.py b/tools/e2e/cases/campaign.py new file mode 100644 index 00000000..b9ce5d6d --- /dev/null +++ b/tools/e2e/cases/campaign.py @@ -0,0 +1,122 @@ +"""战役断点与编队 E2E。""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '战役: 编队、断点和任务插入验证 (需 --with-ocr)' + +_DEFAULT_YAML = str( + Path(__file__).resolve().parents[3] / 'testing' / 'fixtures' / 'campaign_simple_destroyer.yaml' +) +_BREAKPOINTS = ( + 'panel_ready', + 'formation_entered', + 'fleet_checkpoint', + 'before_start', + 'battle_done', +) +_CHECK_TYPES = ('expedition_check', 'reward_check') + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义战役 case 参数。""" + parser.add_argument('--yaml', default=_DEFAULT_YAML, help='战役任务 YAML 路径') + parser.add_argument('--times', type=int, default=1, help='执行战役次数') + parser.add_argument('--pause-at', choices=_BREAKPOINTS, default=None, help='插入检查的断点') + parser.add_argument( + '--check', + choices=_CHECK_TYPES, + default='expedition_check', + help='断点插入的检查任务', + ) + parser.add_argument( + '--ship-name-alias', + action='append', + default=[], + metavar='CUSTOM=STANDARD', + help='用户舰名映射,可重复传入', + ) + + +def _aliases(ctx: Any, values: list[str]) -> dict[str, str] | None: + """合并配置和命令行舰名映射。""" + ocr_config = getattr(getattr(ctx, 'config', None), 'ocr', None) + result = dict(getattr(ocr_config, 'ship_name_aliases', {}) or {}) + for value in values: + alias, separator, standard = value.partition('=') + if not separator or not alias.strip() or not standard.strip(): + return None + result[alias.strip()] = standard.strip() + return result + + +def run(rt: Any) -> bool: + """执行一次战役任务并验证断点交接。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch import Processor, Request + + args = rt.args + if not 1 <= args.times <= 8: + rt.note('times 必须在 1-8 范围内') + return False + aliases = _aliases(rt.ctx, args.ship_name_alias) + if aliases is None: + rt.note('无效舰名映射') + return False + + request = rt.action( + '加载战役任务 YAML', + Request.from_yaml, + args.yaml, + source='cli', + count=args.times, + ship_name_aliases=aliases, + ) + if request is rt.FAILED: + return False + + processor = Processor(rt.ctx) + events: list[str] = [] + paused_page: list[str | None] = [] + interrupted = [False] + + def on_event(event: str, **_data: Any) -> None: + events.append(event) + if event == 'paused': + paused_page.append(identify_current_page(rt.ctx)) + if args.pause_at == event and not interrupted[0]: + interrupted[0] = True + processor.interrupt(Request(task_type=args.check, source='dependency')) + rt.note(f'断点 [{event}] 插入 {args.check}') + + processor.on_event = on_event + processor.submit(request) + outcomes = rt.action('执行战役任务', processor.run_pending) + if outcomes is rt.FAILED: + return False + + campaign_done = [ + result + for status, item, result in outcomes + if status == 'done' and item.task_type == 'campaign' + ] + check_done = [ + item.task_type + for status, item, _result in outcomes + if status == 'done' and item.task_type in _CHECK_TYPES + ] + rt.note(f'事件流水: {events}') + rt.check('战役完成次数一致', lambda: len(campaign_done) == args.times) + if args.pause_at: + rt.check('目标断点已触发', lambda: interrupted[0]) + rt.check('打断后回到首页', lambda: paused_page == [PageName.MAIN.value]) + rt.check('插入检查已完成', lambda: check_done == [args.check]) + rt.check('终态回到首页', lambda: identify_current_page(rt.ctx) == PageName.MAIN.value) + return rt.state.failed == 0 diff --git a/tools/e2e/cases/decisive.py b/tools/e2e/cases/decisive.py new file mode 100644 index 00000000..ac52d62a --- /dev/null +++ b/tools/e2e/cases/decisive.py @@ -0,0 +1,282 @@ +"""决战 E2E — 复用当前配置执行指定轮数的完整决战流程。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '决战: 使用 usersettings.yaml 配置执行完整决战流程 (需 --with-ocr)' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义决战 case 参数。""" + parser.add_argument( + '--times', + type=int, + default=None, + help='覆盖配置中的决战轮数 (默认使用 decisive_battle.decisive_rounds)', + ) + parser.add_argument( + '--scenario', + choices=('full', 'recovery-chain'), + default='full', + help='选择完整决战或四段恢复链路场景', + ) + + +def _run_recovery_chain( # noqa: C901, PLR0911, PLR0912, PLR0915 + rt: Any, + config: Any, +) -> bool: + """Run the four-stage real-device recovery chain without starting combat.""" + from autowsgr.ops import DecisiveController + from autowsgr.types import DecisivePhase + from autowsgr.ui.battle.preparation import BattlePreparationPage + from autowsgr.ui.decisive.preparation import DecisiveBattlePreparationPage + + controller = DecisiveController(rt.ctx, config) + controller._resume_mode = True + controller._has_chosen_fleet = False + + def wait_for_phase( + label: str, + expected: set[DecisivePhase], + ) -> DecisivePhase | None: + def wait_until_phase() -> DecisivePhase: + while controller.state.phase is DecisivePhase.WAITING_FOR_MAP: + controller._handle_waiting_for_map() + return controller.state.phase + + phase = rt.action(label, wait_until_phase) + if phase is rt.FAILED: + return None + rt.note(f'{label}: {phase.name}') + if not rt.check( + f'{label}状态正确', + lambda: controller.state.phase in expected, + ): + return None + return phase + + def wait_for_advance_choice(label: str) -> bool: + phase = wait_for_phase( + label, + {DecisivePhase.USE_LAST_FLEET, DecisivePhase.ADVANCE_CHOICE}, + ) + if phase is None: + return False + if phase is DecisivePhase.USE_LAST_FLEET: + if ( + rt.action( + f'{label}: 识别后选择上次舰队', + controller._handle_use_last_fleet, + ) + is rt.FAILED + ): + return False + phase = wait_for_phase( + f'{label}: 确认后等待前进点选择', + {DecisivePhase.ADVANCE_CHOICE}, + ) + return phase is DecisivePhase.ADVANCE_CHOICE + + def enter_map(label: str) -> bool: + controller._state.phase = DecisivePhase.ENTER_MAP + return rt.action(label, controller._handle_enter_map) is not rt.FAILED + + def reset_after_retreat() -> None: + controller._state.reset() + controller._state.phase = DecisivePhase.ENTER_MAP + + if rt.action('定位决战总览页', controller._prepare_entry_state) is rt.FAILED: + return False + reset_ok = rt.action('Case 1: 重置第六章状态', controller._battle_page.reset_chapter) + if reset_ok is rt.FAILED or not rt.check('Case 1: 第六章重置成功', lambda: bool(reset_ok)): + return False + + # Case 1: first entry -> advance choice -> normal fleet acquisition -> retreat. + if not enter_map('Case 1: 进入第一小关'): + return False + if not wait_for_advance_choice('Case 1: 等待前进点选择'): + return False + if rt.action('Case 1: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + if not wait_for_phase( + 'Case 1: 等待后续状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 1: 正常选择舰队', controller._handle_choose_fleet) is rt.FAILED + ): + return False + if not rt.check( + 'Case 1: 选船后处于准备链路', + lambda: controller.state.phase is DecisivePhase.PREPARE_COMBAT, + ): + return False + if rt.action('Case 1: 不进入战斗直接撤退', controller._execute_retreat) is rt.FAILED: + return False + reset_after_retreat() + + # Case 2: retreat re-entry -> advance choice -> mocked insufficient fleet -> retreat. + if not enter_map('Case 2: 撤退后重新进入'): + return False + if not wait_for_advance_choice('Case 2: 等待前进点选择'): + return False + if rt.action('Case 2: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + + original_best_fleet = controller._logic.get_best_fleet + original_recognize_node = controller._map.recognize_node + original_is_skill_used = controller._map.is_skill_used + + def mock_choose_one_fleet() -> None: + """Click the first real card, then leave only one ship for retreat logic.""" + controller._map.buy_fleet_option((0.25, 0.5)) + controller._state.ships.add(config.level1[0]) + controller._has_chosen_fleet = True + controller._state.phase = DecisivePhase.PREPARE_COMBAT + if not controller._map.close_fleet_overlay(): + raise RuntimeError('mock 购买第一艘舰船后无法关闭战备选择页') + + controller._logic.get_best_fleet = lambda: ['', config.level1[0], '', '', '', '', ''] + controller._map.recognize_node = lambda: 'A' + controller._map.is_skill_used = lambda: True + try: + if not wait_for_phase( + 'Case 2: 等待无船状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 2: mock 跳过识别并选择第一艘', mock_choose_one_fleet) is rt.FAILED + ): + return False + if not rt.check( + 'Case 2: mock 已实际选择一艘舰船', + lambda: config.level1[0] in controller.state.ships, + ): + return False + if rt.action('Case 2: mock 舰船不足判断', controller._handle_prepare_combat) is rt.FAILED: + return False + if not rt.check( + 'Case 2: 舰船不足触发撤退', + lambda: controller.state.phase is DecisivePhase.RETREAT, + ): + return False + finally: + controller._logic.get_best_fleet = original_best_fleet + controller._map.recognize_node = original_recognize_node + controller._map.is_skill_used = original_is_skill_used + + if rt.action('Case 2: 执行撤退', controller._execute_retreat) is rt.FAILED: + return False + reset_after_retreat() + + # Case 3: second retreat re-entry -> advance choice -> normal formation -> leave. + if not enter_map('Case 3: 再次重新进入'): + return False + if not wait_for_advance_choice('Case 3: 等待前进点选择'): + return False + if rt.action('Case 3: 识别后选择前进点', controller._handle_advance_choice) is rt.FAILED: + return False + if not wait_for_phase( + 'Case 3: 等待准备状态', + {DecisivePhase.CHOOSE_FLEET, DecisivePhase.PREPARE_COMBAT}, + ): + return False + if ( + controller.state.phase is DecisivePhase.CHOOSE_FLEET + and rt.action('Case 3: 正常选择舰队', controller._handle_choose_fleet) is rt.FAILED + ): + return False + if rt.action('Case 3: 进入编队页', controller._map.enter_formation) is rt.FAILED: + return False + prep_page = DecisiveBattlePreparationPage(rt.ctx, config, rt.ctx.ocr) + formation_ships = sorted(controller.state.ships)[:6] + if not formation_ships: + rt.note('Case 3: 本轮未记录到已购买舰船') + return False + if ( + rt.action( + 'Case 3: 正常完成编队', + prep_page.change_fleet, + None, + formation_ships, + ) + is rt.FAILED + ): + return False + if rt.action('Case 3: 编队完成回到地图', prep_page.go_back) is rt.FAILED: + return False + if rt.action('Case 3: 暂离', controller._execute_leave) is rt.FAILED: + return False + + # Case 4: leave resume -> no advance choice -> preparation page only. + if rt.action('Case 4: 定位已选节点', controller._prepare_entry_state) is rt.FAILED: + return False + if not enter_map('Case 4: 恢复进入地图'): + return False + if not wait_for_phase( + 'Case 4: 识别恢复后的页面', + {DecisivePhase.PREPARE_COMBAT}, + ): + return False + if rt.action('Case 4: 进入编队页', controller._map.enter_formation) is rt.FAILED: + return False + final_screen = rt.ctx.ctrl.screenshot() + rt.check( + 'Case 4: 停在可出征准备页', + lambda: bool(BattlePreparationPage.is_current_page(final_screen)), + ) + rt.note('Case 4: 未调用 start_battle,验证结束') + return rt.state.failed == 0 + + +def run(rt: Any) -> bool: + """执行决战并验证每轮都有明确结果。""" + from autowsgr.ops import DecisiveController + from autowsgr.ops.decisive.controller import DecisiveResult + + config = rt.ctx.config.decisive_battle + if config is None: + rt.note('usersettings.yaml 未配置 decisive_battle') + return False + if rt.ctx.ocr is None: + rt.note('决战需要 OCR,请使用 --with-ocr') + return False + + times = rt.args.times if rt.args.times is not None else config.decisive_rounds + if times < 1: + rt.note('times 必须大于 0') + return False + + if rt.args.scenario == 'recovery-chain': + return _run_recovery_chain(rt, config) + + rt.note(f'章节: {config.chapter} 轮数: {times}') + rt.note(f'一级舰队: {config.level1}') + rt.note(f'二级舰队: {config.level2}') + + controller = DecisiveController(rt.ctx, config) + results = rt.action( + f'执行决战第 {config.chapter} 章 x{times}', + controller.run_for_times, + times, + ) + if results is rt.FAILED: + return False + + rt.note(f'决战结果: {[result.value for result in results]}') + rt.check('结果轮数一致', lambda: len(results) == times) + rt.check( + '没有 ERROR 结果', + lambda: all(result is not DecisiveResult.ERROR for result in results), + ) + return rt.state.failed == 0 diff --git a/tools/e2e/cases/decisive_stability.py b/tools/e2e/cases/decisive_stability.py new file mode 100644 index 00000000..ad8705b8 --- /dev/null +++ b/tools/e2e/cases/decisive_stability.py @@ -0,0 +1,425 @@ +"""Long-running decisive stability test with controlled leave/retreat injections.""" + +from __future__ import annotations + +import random +import time +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + + +DESC = '决战稳定性长跑: 按参数插入暂离/撤退,并定时检查远征' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--tickets', type=int, default=10, help='决战票数 (默认 10)') + parser.add_argument( + '--force-reset-start', + action='store_true', + help='测试开始时即使入口为 challenging 也尝试识别并重置章节', + ) + parser.add_argument( + '--leaves-per-ticket', + type=int, + default=3, + help='每票注入暂离次数 (默认 3)', + ) + parser.add_argument( + '--retreats-per-ticket', + type=int, + default=1, + help='每票注入撤退次数 (默认 1)', + ) + parser.add_argument( + '--retreat-node', + default=None, + help='指定节点注入一次撤退,例如 B;留空时沿用随机注入顺序', + ) + parser.add_argument('--until', default='08:00', help='持续到本地时间 HH:MM (默认 08:00)') + parser.add_argument( + '--stop-after-tickets', + action='store_true', + help='完成请求票数后立即结束,不进入截止前定时远征等待', + ) + parser.add_argument('--seed', type=int, default=None, help='随机插入顺序种子') + parser.add_argument( + '--expedition-interval', + type=int, + default=300, + help='运行期间远征检查间隔秒数 (默认 300)', + ) + + +def _parse_deadline(raw: str) -> datetime: + hour, minute = (int(value) for value in raw.split(':', 1)) + now = datetime.now().astimezone() + deadline = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if deadline <= now: + deadline += timedelta(days=1) + return deadline + + +def _restart_to_home(rt: Any) -> None: + from autowsgr.ops import ensure_game_ready, restart_game + + app = rt.ctx.config.account.game_app + package = app.package_name if hasattr(app, 'package_name') else app + restart_game(rt.ctx.ctrl, package) + ensure_game_ready(rt.ctx, app) + + +def _reset_after_restart(rt: Any, config: Any, force: bool = False) -> None: + """Reset the chapter before reusing the device after a ticket error.""" + from autowsgr.types import DecisiveEntryStatus + + controller = _new_controller(rt, config) + status = controller._battle_page.detect_entry_status(timeout=10.0) + rt.note(f'异常恢复入口状态: {status.value}') + if status is DecisiveEntryStatus.REFRESHED: + return + if status is DecisiveEntryStatus.CHALLENGING and not force: + rt.note('决战仍在进行,保留当前进度并继续恢复,不执行章节重置') + return + if status not in {DecisiveEntryStatus.REFRESH, DecisiveEntryStatus.CHALLENGING}: + raise RuntimeError(f'异常恢复无法重置章节,入口状态: {status.value}') + if not controller._battle_page.reset_chapter(): + raise RuntimeError('异常恢复重置章节失败') + status = controller._battle_page.detect_entry_status(timeout=10.0) + if status is not DecisiveEntryStatus.REFRESHED: + raise RuntimeError(f'异常恢复重置后状态异常: {status.value}') + + +def _new_controller(rt: Any, config: Any) -> Any: + from autowsgr.ops import DecisiveController + from autowsgr.types import DecisivePhase, PageName + from autowsgr.ui import get_current_page + from autowsgr.ui.battle.preparation import BattlePreparationPage + from autowsgr.ui.decisive.battle_page import DecisiveBattlePage + from autowsgr.ui.decisive.overlay import detect_decisive_overlay, is_decisive_map_page + + controller = DecisiveController(rt.ctx, config) + + screen = rt.ctx.ctrl.screenshot() + current_page = get_current_page(screen) + if is_decisive_map_page(screen) or detect_decisive_overlay(screen) is not None: + raise RuntimeError( + '当前处于决战地图或浮窗;进程外没有可靠的小节状态,拒绝猜测 stage 后继续' + ) + if BattlePreparationPage.is_current_page(screen).matched: + raise RuntimeError('当前处于决战编队页;缺少小节上下文,拒绝盲目恢复') + + if DecisiveBattlePage.is_current_page(screen).matched: + rt.note('启动状态识别: 决战总览页') + controller._battle_page.navigate_to_chapter(config.chapter) + elif current_page in {PageName.MAIN.value, PageName.MAP.value}: + rt.note(f'启动状态识别: {current_page or "未知页面"},导航到决战总览') + controller._prepare_entry_state() + if not DecisiveBattlePage.is_current_page(rt.ctx.ctrl.screenshot()).matched: + raise RuntimeError('导航到决战总览后仍未识别到决战入口') + else: + raise RuntimeError(f'启动状态无法安全接管: {current_page or "未知页面"}') + + # Only an observed overview may start a fresh controller context. + controller._resume_mode = True + controller._has_chosen_fleet = False + controller._full_recovery_check = True + controller._state.phase = DecisivePhase.ENTER_MAP + return controller + + +def _write_report(rt: Any, report: dict[str, Any]) -> None: + lines = [ + '# Decisive Stability Report', + '', + f'- Started: {report["started"]}', + f'- Deadline: {report["deadline"]}', + f'- Seed: {report["seed"]}', + f'- Tickets requested: {report["tickets_requested"]}', + f'- Tickets attempted: {len(report["tickets"])}', + f'- Leaves per ticket: {report["leaves_per_ticket"]}', + f'- Retreats per ticket: {report["retreats_per_ticket"]}', + f'- Retreat node: {report["retreat_node"] or "random"}', + f'- Expedition interval: {report["expedition_interval"]}s', + f'- Expedition collections: {report["expedition_collections"]}', + f'- Restart recoveries: {report["restart_recoveries"]}', + f'- Halted: {report["halted"]}', + '', + '## Tickets', + '', + '| Ticket | Status | Leaves | Retreats | Expeditions | Error |', + '| ---: | --- | ---: | ---: | ---: | --- |', + ] + lines.extend( + '| {ticket} | {status} | {leaves} | {retreats} | {expeditions} | {error} |'.format( + ticket=item['ticket'], + status=item['status'], + leaves=item['leaves'], + retreats=item['retreats'], + expeditions=item['expeditions'], + error=item.get('error', ''), + ) + for item in report['tickets'] + ) + lines.extend(['', '## Events', '']) + lines.extend(f'- {event}' for event in report['events']) + report_path = rt.log_dir / 'stability_report.md' + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + rt.note(f'稳定性报告: {report_path.resolve()}') + + +def _run_ticket( # noqa: C901, PLR0912, PLR0915 + rt: Any, config: Any, ticket: int, rng: random.Random, report: dict[str, Any] +) -> bool: + from autowsgr.ops import collect_expedition + from autowsgr.types import DecisivePhase + + item = { + 'ticket': ticket, + 'status': 'error', + 'leaves': 0, + 'retreats': 0, + 'expeditions': 0, + } + report['tickets'].append(item) + controller = rt.action(f'票 {ticket}: 定位决战总览', _new_controller, rt, config) + if controller is rt.FAILED: + return False + + leaves_per_ticket = max(0, int(getattr(rt.args, 'leaves_per_ticket', 3))) + retreats_per_ticket = max(0, int(getattr(rt.args, 'retreats_per_ticket', 1))) + retreat_node = getattr(rt.args, 'retreat_node', None) + injections = ['leave'] * leaves_per_ticket + ['retreat'] * retreats_per_ticket + if retreat_node: + injections = ['leave'] * leaves_per_ticket + consecutive_system_retreats = 0 + + def recover_and_reenter(label: str) -> None: + if rt.action(label, controller._prepare_entry_state) is rt.FAILED: + raise RuntimeError(label) + controller._state.phase = DecisivePhase.ENTER_MAP + controller._wait_deadline = time.monotonic() + 15.0 + + def collect_expedition_if_due() -> None: + interval = float(getattr(rt.args, 'expedition_interval', 900)) + now = time.monotonic() + if now - report['last_expedition_check'] < interval: + return + collected = rt.action( + f'票 {ticket}: 每 {int(interval)} 秒远征检查', + collect_expedition, + rt.ctx, + ) + if collected is rt.FAILED: + raise RuntimeError('定时远征收取失败') + item['expeditions'] += int(bool(collected)) + report['expedition_collections'] += int(bool(collected)) + report['last_expedition_check'] = time.monotonic() + + handlers = { + DecisivePhase.ENTER_MAP: controller._handle_enter_map, + DecisivePhase.WAITING_FOR_MAP: controller._handle_waiting_for_map, + DecisivePhase.USE_LAST_FLEET: controller._handle_use_last_fleet, + DecisivePhase.DOCK_FULL: controller._handle_dock_full, + DecisivePhase.CHOOSE_FLEET: controller._handle_choose_fleet, + DecisivePhase.ADVANCE_CHOICE: controller._handle_advance_choice, + DecisivePhase.PREPARE_COMBAT: controller._handle_prepare_combat, + DecisivePhase.IN_COMBAT: controller._handle_combat, + DecisivePhase.NODE_RESULT: controller._handle_node_result, + DecisivePhase.STAGE_CLEAR: controller._handle_stage_clear, + } + + try: + while time.monotonic() < report['deadline_monotonic']: + collect_expedition_if_due() + phase = controller._state.phase + if phase is DecisivePhase.CHAPTER_CLEAR: + item['status'] = 'clear' + break + if phase in {DecisivePhase.IN_COMBAT, DecisivePhase.STAGE_CLEAR}: + consecutive_system_retreats = 0 + + targeted_retreat = ( + phase is DecisivePhase.PREPARE_COMBAT + and retreat_node + and controller._state.node == retreat_node + and item['retreats'] < retreats_per_ticket + ) + if targeted_retreat or (phase is DecisivePhase.PREPARE_COMBAT and injections): + injection = 'retreat' if targeted_retreat else injections.pop(0) + if injection == 'leave': + if ( + rt.action( + f'票 {ticket}: 暂离 #{item["leaves"] + 1}', controller._execute_leave + ) + is rt.FAILED + ): + raise RuntimeError('暂离失败') + item['leaves'] += 1 + report['events'].append(f'票 {ticket}: leave {item["leaves"]}') + collected = rt.action( + f'票 {ticket}: 暂离后收取远征', + collect_expedition, + rt.ctx, + ) + if collected is rt.FAILED: + raise RuntimeError('远征收取失败') + item['expeditions'] += int(bool(collected)) + report['expedition_collections'] += int(bool(collected)) + recover_and_reenter(f'票 {ticket}: 暂离后重新进入决战') + else: + if ( + rt.action( + f'票 {ticket}: 撤退 #{item["retreats"] + 1}', + controller._execute_retreat, + ) + is rt.FAILED + ): + raise RuntimeError('撤退失败') + item['retreats'] += 1 + report['events'].append(f'票 {ticket}: retreat {item["retreats"]}') + controller._state.reset() + controller._advance_source_node = None + controller._state.phase = DecisivePhase.ENTER_MAP + recover_and_reenter(f'票 {ticket}: 撤退后重新进入决战') + continue + + if phase is DecisivePhase.RETREAT: + consecutive_system_retreats += 1 + if consecutive_system_retreats > 5: + report['halted'] = True + raise RuntimeError('连续系统撤退超过 5 次,决战没有取得进展') + if ( + rt.action(f'票 {ticket}: 处理系统撤退', controller._execute_retreat) + is rt.FAILED + ): + raise RuntimeError('系统撤退失败') + controller._state.reset() + controller._advance_source_node = None + controller._state.phase = DecisivePhase.ENTER_MAP + recover_and_reenter(f'票 {ticket}: 系统撤退后恢复') + continue + + if phase is DecisivePhase.LEAVE: + if rt.action(f'票 {ticket}: 处理系统暂离', controller._execute_leave) is rt.FAILED: + raise RuntimeError('系统暂离失败') + recover_and_reenter(f'票 {ticket}: 系统暂离后恢复') + continue + + handler = handlers.get(phase) + if handler is None: + raise RuntimeError(f'未知决战阶段: {phase}') + if rt.action(f'票 {ticket}: {phase.name}', handler) is rt.FAILED: + raise RuntimeError(f'决战阶段失败: {phase.name}') + else: + item['error'] = '达到稳定性测试截止时间' + except Exception as exc: + item['error'] = str(exc) + report['events'].append(f'票 {ticket}: error={exc}') + if rt.action(f'票 {ticket}: 异常后重启游戏回首页', _restart_to_home, rt) is not rt.FAILED: + if ( + rt.action( + f'票 {ticket}: 异常后重置决战章节', + _reset_after_restart, + rt, + config, + ) + is not rt.FAILED + ): + report['restart_recoveries'] += 1 + else: + report['halted'] = True + else: + report['halted'] = True + return item['status'] == 'clear' and not item.get('error') and not injections + + +def run(rt: Any) -> bool: + from autowsgr.ops import collect_expedition + + config = rt.ctx.config.decisive_battle + if config is None: + rt.note('usersettings.yaml 未配置 decisive_battle') + return False + if rt.ctx.ocr is None: + rt.note('决战稳定性测试需要 OCR,请使用 --with-ocr') + return False + if rt.args.tickets < 1: + rt.note('tickets 必须大于 0') + return False + + started = datetime.now().astimezone() + deadline = _parse_deadline(rt.args.until) + seed = rt.args.seed if rt.args.seed is not None else random.SystemRandom().randrange(1 << 30) + report: dict[str, Any] = { + 'started': started.isoformat(timespec='seconds'), + 'deadline': deadline.isoformat(timespec='seconds'), + 'deadline_monotonic': time.monotonic() + max(0.0, (deadline - started).total_seconds()), + 'seed': seed, + 'tickets_requested': rt.args.tickets, + 'leaves_per_ticket': max(0, int(getattr(rt.args, 'leaves_per_ticket', 3))), + 'retreats_per_ticket': max(0, int(getattr(rt.args, 'retreats_per_ticket', 1))), + 'retreat_node': getattr(rt.args, 'retreat_node', None), + 'expedition_interval': int(getattr(rt.args, 'expedition_interval', 900)), + 'tickets': [], + 'expedition_collections': 0, + 'restart_recoveries': 0, + 'halted': False, + 'events': [], + 'last_expedition_check': time.monotonic(), + } + rng = random.Random(seed) + rt.note( + f'稳定性测试: tickets={rt.args.tickets}, until={deadline.isoformat(timespec="seconds")}, seed={seed}' + ) + + if ( + rt.action( + '测试开始: 确保决战章节已重置', + _reset_after_restart, + rt, + config, + bool(getattr(rt.args, 'force_reset_start', False)), + ) + is rt.FAILED + ): + report['halted'] = True + report['events'].append('稳定性测试因初始章节状态恢复失败而停止') + _write_report(rt, report) + return False + + for ticket in range(1, rt.args.tickets + 1): + if time.monotonic() >= report['deadline_monotonic']: + break + _run_ticket(rt, config, ticket, rng, report) + if report['halted']: + report['events'].append('稳定性测试因异常恢复失败而停止') + break + + if report['halted']: + _write_report(rt, report) + return False + + if not rt.args.stop_after_tickets: + while time.monotonic() < report['deadline_monotonic']: + collected = rt.action('截止前定时远征检查', collect_expedition, rt.ctx) + if collected is not rt.FAILED: + report['expedition_collections'] += int(bool(collected)) + time.sleep( + min( + float(rt.args.expedition_interval), + max(1.0, report['deadline_monotonic'] - time.monotonic()), + ) + ) + + _write_report(rt, report) + return bool(report['tickets']) and all( + item['status'] == 'clear' + and item['leaves'] >= report['leaves_per_ticket'] + and item['retreats'] >= report['retreats_per_ticket'] + for item in report['tickets'] + ) diff --git a/tools/e2e/cases/exercise.py b/tools/e2e/cases/exercise.py new file mode 100644 index 00000000..f415ede0 --- /dev/null +++ b/tools/e2e/cases/exercise.py @@ -0,0 +1,529 @@ +"""演习断点打断 E2E — 处理器暂停 + 后勤检查插入 + 重跑计数。 + +验证用户敲定的五个场景 (一次运行验证一个断点场景, 演习对手打完即无): + 1. --pause-at panel_ready 导航结束后暂停 → 插后勤检查 → 回主页 + 2. --pause-at formation_entered 进入编队后暂停 → 插后勤检查 → 回主页 + 3. --pause-at ship_selected 选船完成后暂停 → 插后勤检查 → 回主页 + 4. --pause-at before_start 出征前暂停 → 插后勤检查 → 回主页 + 5. --pause-at rival_done 战斗完成后暂停 (计数保留) → 插后勤检查 → 回主页 + 6. 不带 --pause-at 直接跑完并统计计数 + +多轮模式 (--rounds N): + 每个请求只挑战一个对手,默认按 2,3 队伍循环;前两轮连续执行, + 第二轮后插入一次后勤任务验证非连续导航。``--rounds 6`` 用于完整六轮验证。 + +接力模式 (--relay): + 同一趟演习里依次覆盖导航入口、进入编队、一次选船、出征前和战斗结束 + 五个交接断点,每次插入一次后勤检查并自动恢复; 配合 --with-init + 在演习前先跑初始化链路 (含每日浮层清理)。 + +核心语义 (2026-08 用户敲定): 一次提交 = 持续打到没有对手 (以「打一个」 +为基础单元循环); 每打完一个对手计数 +1; 中间被更高优先级任务打断时在断点 +暂停 → 高优任务执行 → 恢复后接着打剩余的 → 直到没有对手。 + +打断机制: 事件回调里收到目标事件 → processor.interrupt(后勤检查请求) +→ 演习执行器在最近的 _wait 断点回主页、抛 TaskPaused → 处理器清信号、 +重排队 → 后勤检查先跑 → 演习重跑 (已打对手变灰自动跳过, 计数保留)。 + +用法:: + + # 场景4: 打完一个对手后打断一次 + python tools/e2e/run.py --with-ocr exercise --pause-at rival_done + + # 场景5: 不打断, 跑完全部并统计 + python tools/e2e/run.py --with-ocr exercise + + # 接力: 初始化(清弹窗) → 五类断点各插一次后勤检查 → 打完 + python tools/e2e/run.py --with-ocr exercise --relay --with-init + + # 换计划 YAML + python tools/e2e/run.py --with-ocr exercise --yaml <路径> + + # 六轮演习: 五个断点 + 队伍 2/3 切换 + 连续/非连续导航 + python tools/e2e/run.py --with-ocr --fast-ocr exercise --rounds 6 \ + --fleet-sequence 2,3 --continuous-rounds 2 --relay --with-init \ + --checks expedition_check,reward_check,expedition_check,reward_check,expedition_check +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '演习: 断点/多轮队伍切换/任务衔接验证 (需 --with-ocr)' + +# 默认计划: GUI 系统预设的队伍2演习 (用户提供的驱动 YAML) +_DEFAULT_YAML = str( + Path(__file__).resolve().parents[3] / 'testing' / 'fixtures' / 'exercise_team2.yaml' +) + +# 可单独验证的断点事件;旧的 rival_confirmed/fleet_ready 继续保留兼容。 +_BREAKPOINTS = ( + 'panel_ready', + 'rival_confirmed', + 'formation_entered', + 'fleet_ready', + 'ship_selected', + 'before_start', + 'rival_done', +) + +# 接力模式覆盖用户要求的五类交接节点;每类只在第一次上报时触发。 +_RELAY_PAUSES = ( + 'panel_ready', + 'formation_entered', + 'ship_selected', + 'before_start', + 'rival_done', +) + +_CHECK_TYPES = ('expedition_check', 'reward_check') + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument('--yaml', default=_DEFAULT_YAML, help='演习计划 YAML 路径') + parser.add_argument('--fleet-id', type=int, default=None, help='仅本次 E2E 覆盖舰队编号') + parser.add_argument( + '--ship-name-alias', + action='append', + default=[], + metavar='CUSTOM=STANDARD', + help='用户舰名映射,可重复传入', + ) + parser.add_argument( + '--rivals-limit', + type=int, + default=None, + help='仅本次 E2E 限制挑战对手数量,便于分次验证', + ) + parser.add_argument( + '--pause-at', + choices=_BREAKPOINTS, + default=None, + help='在哪个断点触发处理器打断 (不指定 = 不打断直接跑完)', + ) + parser.add_argument( + '--relay', + action='store_true', + help='接力模式: 五类交接断点依次各打断一次', + ) + parser.add_argument( + '--with-init', + action='store_true', + help='演习前先跑初始化链路 (任意状态 → 首页 + 每日浮层清理)', + ) + parser.add_argument( + '--check', + choices=('expedition_check', 'reward_check'), + default='expedition_check', + help='断点插入的后勤检查任务 (默认: expedition_check)', + ) + parser.add_argument( + '--checks', + default=None, + help='接力模式按断点顺序插入的检查任务, 逗号分隔 (共 5 项)', + ) + parser.add_argument('--rounds', type=int, default=1, help='按单场请求执行的演习轮数 (最多 6)') + parser.add_argument( + '--fleet-sequence', + default='2,3', + help='多轮模式循环使用的队伍编号,例如 2,3', + ) + parser.add_argument( + '--continuous-rounds', + type=int, + default=2, + help='多轮模式中连续任务阶段的轮数', + ) + + +def _resolve_checks(args: Any) -> tuple[str, ...]: + """解析断点检查序列; 未指定序列时保留旧的单检查行为。""" + raw = getattr(args, 'checks', None) + if raw is None: + checks = (args.check,) * len(_RELAY_PAUSES) if args.relay else (args.check,) + else: + checks = tuple(item.strip() for item in raw.split(',') if item.strip()) + if any(check not in _CHECK_TYPES for check in checks): + raise ValueError(f'检查任务必须属于: {", ".join(_CHECK_TYPES)}') + expected = len(_RELAY_PAUSES) if args.relay else 1 + if len(checks) != expected: + raise ValueError(f'当前模式需要 {expected} 个检查任务, 收到 {len(checks)} 个') + return checks + + +def _resolve_rounds(args: Any) -> tuple[int, tuple[int, ...], int]: + """校验多轮演习的次数、队伍循环和连续阶段边界。""" + rounds = int(getattr(args, 'rounds', 1)) + if not 1 <= rounds <= 6: + raise ValueError('rounds 必须在 1-6 范围内') + sequence = tuple( + int(value.strip()) + for value in str(getattr(args, 'fleet_sequence', '2,3')).split(',') + if value.strip() + ) + if not sequence or any(fleet_id not in range(1, 5) for fleet_id in sequence): + raise ValueError('fleet-sequence 必须是 1-4 的队伍编号列表') + continuous = int(getattr(args, 'continuous_rounds', 2)) + if not 1 <= continuous < rounds: + raise ValueError('continuous-rounds 必须小于 rounds 且至少为 1') + return rounds, sequence, continuous + + +def _collect_aliases(ctx: Any, args: Any) -> dict[str, str] | None: + """合并用户配置和命令行舰名映射。""" + ocr_config = getattr(getattr(ctx, 'config', None), 'ocr', None) + aliases = dict(getattr(ocr_config, 'ship_name_aliases', {}) or {}) + for value in args.ship_name_alias: + alias, separator, standard = value.partition('=') + if not separator or not alias.strip() or not standard.strip(): + return None + aliases[alias.strip()] = standard.strip() + return aliases + + +def _run_multiple_rounds( # noqa: PLR0915 - one E2E case owns the full scenario assertions + rt: Any, + ctx: Any, + args: Any, + aliases: dict[str, str], + checks: tuple[str, ...], +) -> bool: + """提交多个单场请求,验证队伍切换和连续/非连续任务衔接。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + rounds, fleet_sequence, continuous_rounds = _resolve_rounds(args) + processor = Processor(ctx) + requests: list[Request] = [] + request_index: dict[str, int] = {} + events: list[tuple[str, dict[str, Any]]] = [] + snapshots: dict[str, Any] = { + 'paused_pages': [], + 'paused_rounds': [], + 'round_start_pages': [], + 'noncontinuous_check': False, + } + + for round_number in range(1, rounds + 1): + fleet_id = fleet_sequence[(round_number - 1) % len(fleet_sequence)] + request = rt.action( + f'加载第 {round_number} 轮演习 YAML(队伍 {fleet_id})', + Request.from_yaml, + args.yaml, + source='cli', + ship_name_aliases=aliases, + ) + if request is rt.FAILED: + return False + request = replace( + request, + params={ + **request.params, + 'fleet_id': fleet_id, + 'rivals_limit': 1, + }, + ) + requests.append(request) + request_index[request.request_id] = round_number + + relay_index = [0] + relay_fired = [False] * len(_RELAY_PAUSES) + scheduled_rounds = {1} + + def interrupt_now(event: str, check: str) -> None: + rt.note(f'>> 断点 [{event}] 触发后勤任务: {check}') + processor.interrupt(Request(task_type=check, source='dependency')) + + def on_event(event: str, **data: Any) -> None: + events.append((event, dict(data))) + round_number = request_index.get(str(data.get('task_id', ''))) + if event == 'running' and data.get('task_type') == 'exercise': + page = identify_current_page(ctx) + snapshots['round_start_pages'].append((round_number, page)) + if event == 'paused': + page = identify_current_page(ctx) + snapshots['paused_pages'].append(page) + snapshots['paused_rounds'].append(round_number) + if args.relay: + index = relay_index[0] + if ( + index < len(_RELAY_PAUSES) + and event == _RELAY_PAUSES[index] + and not relay_fired[index] + ): + relay_fired[index] = True + relay_index[0] = index + 1 + interrupt_now(event, checks[index]) + if ( + event == 'completed' + and data.get('task_type') == 'exercise' + and round_number is not None + and round_number < rounds + and round_number + 1 not in scheduled_rounds + ): + if round_number == continuous_rounds and not snapshots['noncontinuous_check']: + snapshots['noncontinuous_check'] = True + processor.submit(Request(task_type=args.check, source='dependency')) + rt.note('>> 连续任务阶段结束,插入后勤任务验证非连续导航') + processor.submit(requests[round_number]) + scheduled_rounds.add(round_number + 1) + + processor.on_event = on_event + processor.submit(requests[0]) + outcomes = rt.action(f'执行{rounds}轮单场演习任务', processor.run_pending) + if outcomes is rt.FAILED: + return False + + done_exercises = [ + (request, result) + for status, request, result in outcomes + if status == 'done' and request.task_type == 'exercise' + ] + done_types = [request.task_type for status, request, _ in outcomes if status == 'done'] + expected_fleets = [fleet_sequence[i % len(fleet_sequence)] for i in range(rounds)] + actual_fleets = [request.params.get('fleet_id') for request, _ in done_exercises] + + rt.note(f'{rounds}轮事件流水: {[event for event, _ in events]}') + rt.note(f'任务完成流水: {done_types}') + rt.check(f'{rounds}轮演习全部完成', lambda: len(done_exercises) == rounds) + rt.check( + '每轮只挑战一个对手', + lambda: all(isinstance(result, list) and len(result) == 1 for _, result in done_exercises), + ) + rt.check('队伍按 2→3 循环切换', lambda: actual_fleets == expected_fleets) + rt.check( + '连续任务阶段导航从首页开始', + lambda: all(page == PageName.MAIN for _, page in snapshots['round_start_pages']), + ) + rt.check( + '连续任务存在相邻演习请求', + lambda: any( + done_types[i : i + 2] == ['exercise', 'exercise'] for i in range(len(done_types) - 1) + ), + ) + rt.check( + '非连续任务经过后勤检查', + lambda: ( + snapshots['noncontinuous_check'] + and any( + done_types[i] == 'exercise' + and done_types[i + 1] in _CHECK_TYPES + and done_types[i + 2] == 'exercise' + for i in range(len(done_types) - 2) + ) + ), + ) + if args.relay: + rt.check('五个断点全部触发', lambda: all(relay_fired)) + rt.check( + '断点暂停均回到首页', + lambda: ( + len(snapshots['paused_pages']) >= len(_RELAY_PAUSES) + and all(page == PageName.MAIN for page in snapshots['paused_pages']) + ), + ) + completed_checks = [ + request.task_type + for status, request, _ in outcomes + if status == 'done' and request.task_type in _CHECK_TYPES + ] + rt.check( + '断点后勤检查按顺序执行', + lambda: completed_checks[: len(checks)] == list(checks), + ) + rt.check(f'{rounds}轮任务最终回到首页', lambda: identify_current_page(ctx) == PageName.MAIN) + return rt.state.failed == 0 + + +def run(rt: Any) -> bool: # noqa: C901, PLR0912, PLR0915 - one E2E case owns its assertions + """执行演习断点打断验证 (单断点 / 接力 / 可带初始化前置)。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + args = rt.args + ctx = rt.ctx + checks = _resolve_checks(args) + rounds = int(getattr(args, 'rounds', 1)) + rt.note(f'计划: {args.yaml}') + if args.relay: + rt.note(f'模式: 接力 ({">".join(_RELAY_PAUSES)})') + else: + rt.note(f'打断点: {args.pause_at or "(不打断, 场景5)"}') + rt.note(f'插入检查: {checks}') + + # ── 阶段零 (可选): 初始化链路 + 每日浮层清理 ──────────────── + if args.with_init: + from autowsgr.application.runtime.initialize.initialize import initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + + if rt.action('初始化 (任意状态 → 首页 + 清浮层)', initialize, ctx) is rt.FAILED: + return False + rt.check('初始化后: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check( + '初始化后: 无浮层残留', + lambda: detect_overlay(ctx.ctrl.screenshot()) is None, + ) + + # ── 准备: 演习请求 (YAML 驱动) + 处理器 ───────────────────── + aliases = _collect_aliases(ctx, args) + if aliases is None: + rt.note('无效舰名映射') + return False + if rounds > 1: + return _run_multiple_rounds(rt, ctx, args, aliases, checks) + exercise_req = rt.action( + '加载演习计划 YAML', + Request.from_yaml, + args.yaml, + source='cli', + ship_name_aliases=aliases, + ) + if exercise_req is rt.FAILED: + return False + overrides: dict[str, Any] = {} + if args.fleet_id is not None: + overrides['fleet_id'] = args.fleet_id + if args.rivals_limit is not None: + overrides['rivals_limit'] = args.rivals_limit + if overrides: + exercise_req = replace( + exercise_req, + params={**exercise_req.params, **overrides}, + ) + rt.note(f'task_type={exercise_req.task_type} params={exercise_req.params} → 持续打到没有对手') + + processor = Processor(ctx) + events: list[tuple[str, dict]] = [] # 事件流水 (供断言与人工核对) + snapshots: dict[str, Any] = {} # 关键时刻的状态快照 + # 接力模式: 当前断点队列下标 + 各断点是否已触发 (重跑会重复上报事件) + relay_index = [0] + relay_fired: list[bool] = [False] * len(_RELAY_PAUSES) + + def interrupt_now(event: str, check: str) -> None: + """在断点插入后勤检查 (处理器加急)。""" + rt.note(f'>> 断点 [{event}] 触发处理器加急: 插入 {check}') + processor.interrupt(Request(task_type=check, source='dependency')) + + def on_event(event: str, **data: Any) -> None: + events.append((event, dict(data))) + if event == 'paused': + # paused 上报发生在回主页之后 → 立即验证锚点铁律 + page = identify_current_page(ctx) + fought = exercise_req.progress.get('fought', 0) + if args.relay: + snapshots.setdefault('paused_pages', []).append(page) + snapshots.setdefault('paused_fought', []).append(fought) + else: + snapshots['paused_page'] = page + snapshots['paused_fought'] = fought + if args.relay: + # 依次消费断点队列: 每个断点只在第一次上报时触发 + idx = relay_index[0] + if idx < len(_RELAY_PAUSES) and event == _RELAY_PAUSES[idx] and not relay_fired[idx]: + relay_fired[idx] = True + relay_index[0] = idx + 1 + interrupt_now(event, checks[idx]) + elif event == args.pause_at and not snapshots.get('interrupted'): + # 到达目标断点 → 模拟下游依赖加急插入后勤检查 + snapshots['interrupted'] = True + interrupt_now(event, checks[0]) + + processor.on_event = on_event + + # ── 阶段一: 首次提交 (可能被断点打断后恢复) ────────────────── + processor.submit(exercise_req) + outcomes = rt.action('首次提交 (演习 + 可能的打断恢复)', processor.run_pending) + if outcomes is rt.FAILED: + return False + + status_flow = [(status, req.task_type) for status, req, _ in outcomes] + rt.note(f'执行流: {status_flow}') + rt.note(f'事件流水: {[e for e, _ in events]}') + + if args.relay: + # 接力模式: 演习暂停 x5 → 后勤检查 x5 → 演习重跑完成 + expected_flow: list[tuple[str, str]] = [] + for check in checks: + expected_flow += [('paused', 'exercise'), ('done', check)] + expected_flow.append(('done', 'exercise')) + rt.check('执行流 = (暂停→后勤检查) x5 → 重跑完成', lambda: status_flow == expected_flow) + rt.check('五个交接断点全部触发过', lambda: all(relay_fired)) + paused_pages = snapshots.get('paused_pages', []) + rt.check( + '每次打断都在主页面 (锚点铁律)', + lambda: ( + len(paused_pages) == len(_RELAY_PAUSES) + and all(page == PageName.MAIN for page in paused_pages) + ), + ) + elif args.pause_at: + # 打断场景: 演习暂停 → 后勤检查先跑 → 演习重跑完成 + rt.check( + '执行流 = 暂停 → 后勤检查 → 重跑完成', + lambda: ( + status_flow == [('paused', 'exercise'), ('done', checks[0]), ('done', 'exercise')] + ), + ) + rt.check( + '打断时已回到主页面 (锚点铁律)', lambda: snapshots.get('paused_page') == PageName.MAIN + ) + rt.check(f'打断点 [{args.pause_at}] 确实触发过', lambda: bool(snapshots.get('interrupted'))) + if args.pause_at == 'rival_done': + # 战斗完成后打断: 计数已保留 (打过的那一场不丢) + rt.check( + '打断时计数已保留 (fought >= 1)', lambda: snapshots.get('paused_fought', 0) >= 1 + ) + else: + # 场景5: 不打断, 一趟完成 + rt.check('执行流 = 单趟完成', lambda: status_flow == [('done', 'exercise')]) + + # ── 计数统计: 一次提交打光全部 (rival_done 逐场累计) ───────── + done_results = next( + (r for s, req, r in outcomes if s == 'done' and req.task_type == 'exercise'), + [], + ) + total = exercise_req.progress.get('fought', 0) + # 最后一次打断时的保留计数 (接力 = 打一场后; 单断点 = 该断点时刻; 无打断 = 0) + paused_fought = ( + snapshots.get('paused_fought', [0])[-1] if args.relay else snapshots.get('paused_fought', 0) + ) + rt.note( + f'计数统计: 本任务共打 {total} 场 ' + f'(打断时已保留 {paused_fought} 场, 重跑补打 {len(done_results)} 场)', + ) + if total == 0: + # 无可挑战对手 (今日该时段已打完) — 空完成本身是正确行为, 不算失败 + rt.note('无可挑战对手 (今日该时段已打完), 空完成收尾属正常') + else: + rt.check( + '每场战斗都有 rival_done 计数 (一场一计)', + lambda: len(done_results) == total - paused_fought, + ) + rt.check('累计计数 = 暂停保留 + 重跑场数', lambda: total == paused_fought + len(done_results)) + + # 后勤检查执行过 (打断场景) 且终态回主页 + if args.relay or args.pause_at: + rt.check( + '后勤检查按断点顺序执行', + lambda: ( + [ + req.task_type + for status, req, _ in outcomes + if status == 'done' and req.task_type in _CHECK_TYPES + ] + == list(checks) + ), + ) + rt.check('终态: 主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + return rt.state.failed == 0 diff --git a/tools/e2e/cases/initialize.py b/tools/e2e/cases/initialize.py new file mode 100644 index 00000000..07c5279f --- /dev/null +++ b/tools/e2e/cases/initialize.py @@ -0,0 +1,55 @@ +"""初始化链路 E2E — application.runtime.initialize 实机验证。 + +验证终态契约: 任意状态 → [首页 + 浮层已清 + 待机]。 +配合 ``--no-launch`` 使用 (跳过框架自己的 ensure_ready, 让 initialize 全权处理): +- 默认: 游戏保持当前状态, 验证分支 1/2 (已在首页 / 游戏内导航回首页) +- ``--cold``: 先强杀游戏, 验证完整冷启动分支 3 (启动 → 进入 → 首页 → 清浮层) + +用法:: + + python tools/e2e/run.py --no-launch initialize + python tools/e2e/run.py --no-launch initialize --cold +""" + +from __future__ import annotations + +import time + + +DESC = '初始化链路: 任意状态 → 首页待机 (SL 兜底)' + + +def add_arguments(parser) -> None: + """case 专属参数。""" + parser.add_argument('--cold', action='store_true', help='先强杀游戏再初始化 (测冷启动分支)') + + +def run(rt) -> bool: + """执行初始化链路并验证终态契约。""" + from autowsgr.application.runtime.initialize.initialize import _package_of, initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + from autowsgr.application.ui.navigation import identify_current_page + + ctx = rt.ctx + + # ① 初始状态记录 (对照用, 不参与判定) + start_page = rt.action('识别初始页面', identify_current_page, ctx) + if start_page is not rt.FAILED: + rt.note(f'初始页面: {start_page}') + + # ② --cold: 强杀游戏 → 强制走冷启动分支 + if rt.args.cold: + package = _package_of(ctx) + if rt.action(f'强杀游戏 ({package})', ctx.ctrl.stop_app, package) is rt.FAILED: + return False + time.sleep(2.0) + + # ③ 主体: initialize (内部含三分支判定 + SL 兜底) + if rt.action('执行 initialize', initialize, ctx) is rt.FAILED: + return False + + # ④ 终态契约验证: 首页 + 浮层已清 + rt.check('终态: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check('终态: 无浮层残留', lambda: detect_overlay(ctx.ctrl.screenshot()) is None) + return rt.state.failed == 0 diff --git a/tools/e2e/cases/normal_fight.py b/tools/e2e/cases/normal_fight.py new file mode 100644 index 00000000..75cd1961 --- /dev/null +++ b/tools/e2e/cases/normal_fight.py @@ -0,0 +1,63 @@ +"""常规战快速验证 case — 接入现有 ops 操作的完整示例。 + +复用生产同款 ``run_normal_fight_from_yaml``, 用于快速实机验证: +计划加载 → 编队 → 多节点战斗 → 结算的常规战全链路。 +编队识别依赖 OCR, 请配 ``--with-ocr`` 运行。 + +用法:: + + # 默认跑 1 次 1-1 (内置计划) + python tools/e2e/run.py normal_fight --with-ocr + + # 指定计划与次数 + python tools/e2e/run.py normal_fight --with-ocr --plan 7-4千伪 --times 3 + + # 指定舰队编号 (默认用计划内配置) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --fleet 2 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '常规战: 跑 N 次指定作战计划 (需 --with-ocr)' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument('--plan', default='1-1', help='计划名或 YAML 路径 (默认 1-1)') + parser.add_argument('--times', type=int, default=1, help='执行次数 (默认 1)') + parser.add_argument('--fleet', type=int, default=None, help='舰队编号 (默认用计划配置)') + + +def run(rt: Any) -> bool: + """执行常规战验证步骤。""" + from autowsgr.business.combat.bettle import run_normal_fight_from_yaml + + args = rt.args + rt.note(f'计划: {args.plan} 次数: {args.times} 舰队: {args.fleet or "计划配置"}') + + # 步骤1: 执行常规战 (复用生产 ops, 内部含计划加载/编队/战斗/结算) + results = rt.action( + f'执行常规战 {args.plan} x{args.times}', + run_normal_fight_from_yaml, + rt.ctx, + args.plan, + times=args.times, + fleet_id=args.fleet, + ) + if results is rt.FAILED: + return False + + # 步骤2: 核对战斗次数 + ok = rt.check('战斗次数一致', lambda: len(results) == args.times) + + # 步骤3: 打印每场战果概况 (不计步, 供人工核对) + for i, r in enumerate(results, 1): + rt.note(f'第{i}场: flag={r.flag.name} 节点={r.node_count}') + + return ok diff --git a/tools/e2e/cases/reward.py b/tools/e2e/cases/reward.py new file mode 100644 index 00000000..eb46547a --- /dev/null +++ b/tools/e2e/cases/reward.py @@ -0,0 +1,99 @@ +"""奖励收取 E2E — reward_check 执行器 (处理器路径) + 兼容层 collect_rewards。 + +验证: + 1. 处理器路径: 提交 ``reward_check`` → 执行流 = [('done', 'reward_check')], + 事件流水含 ``checked``, 终态回主页 (锚点铁律)。 + 2. 兼容层路径: 直接调 ``collect_rewards(ctx)`` (auto_daily 定时器的调用方式), + 返回 bool, 调用后仍在主页。 + +无任务红点时执行器在主页空跑返回 (collected=0), 属正常安全行为; +断言只要求链路正确走完, 不要求本次一定收没收到奖励。 + +用法:: + + # 直接验证 (不初始化) + python tools/e2e/run.py reward + + # 先跑初始化链路 (任意状态 → 首页 + 每日浮层清理) + python tools/e2e/run.py reward --with-init +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import argparse + +DESC = '奖励收取: reward_check 执行器 + 兼容层 collect_rewards' + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + """定义 case 专属命令行参数。""" + parser.add_argument( + '--with-init', + action='store_true', + help='验证前先跑初始化链路 (任意状态 → 首页 + 每日浮层清理)', + ) + + +def run(rt: Any) -> bool: + """执行奖励收取验证: 处理器路径 → 兼容层路径 → 锚点断言。""" + from autowsgr.application.ui.navigation import identify_current_page + from autowsgr.business.logistics.reward import collect_rewards + from autowsgr.common.types import PageName + from autowsgr.dispatch.processor import Processor, Request + + ctx = rt.ctx + + # ── 阶段零 (可选): 初始化链路 + 每日浮层清理 ──────────────── + if rt.args.with_init: + from autowsgr.application.runtime.initialize.initialize import initialize + from autowsgr.application.ui.controller.main_page import MainPage + from autowsgr.application.ui.controller.main_page.overlays import detect_overlay + + if rt.action('初始化 (任意状态 → 首页 + 清浮层)', initialize, ctx) is rt.FAILED: + return False + rt.check('初始化后: 主页面基础态', MainPage.is_base_page, ctx.ctrl.screenshot()) + rt.check( + '初始化后: 无浮层残留', + lambda: detect_overlay(ctx.ctrl.screenshot()) is None, + ) + + # ── 阶段一: 处理器路径 (submit → run_pending) ──────────────── + req = Request(task_type='reward_check', source='cli') + events: list[str] = [] + + def on_event(event: str, **data: Any) -> None: + events.append(event) + + processor = Processor(ctx, on_event=on_event) + processor.submit(req) + outcomes = rt.action('提交 reward_check → 处理器执行', processor.run_pending) + if outcomes is rt.FAILED: + return False + + status_flow = [(status, r.task_type) for status, r, _ in outcomes] + rt.note(f'执行流: {status_flow}') + rt.note(f'事件流水: {events}') + + rt.check( + '执行流 = 单趟完成 (reward_check)', + lambda: status_flow == [('done', 'reward_check')], + ) + rt.check('上报了 checked 事件', lambda: 'checked' in events) + rt.check( + 'checked 事件只有一次 (短链路不重跑)', + lambda: events.count('checked') == 1, + ) + rt.check('终态: 主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + # ── 阶段二: 兼容层路径 (auto_daily 的调用方式) ─────────────── + result = rt.action('兼容层 collect_rewards(ctx)', collect_rewards, ctx) + if result is rt.FAILED: + return False + rt.check('collect_rewards 返回 bool', lambda: isinstance(result, bool)) + rt.check('兼容层调用后仍在主页面', lambda: identify_current_page(ctx) == PageName.MAIN) + + return rt.state.failed == 0 diff --git a/tools/e2e/cases/screenshot.py b/tools/e2e/cases/screenshot.py new file mode 100644 index 00000000..03aaf716 --- /dev/null +++ b/tools/e2e/cases/screenshot.py @@ -0,0 +1,31 @@ +"""链路自检 case — 最小可用示例: ADB 连接 + 截图 + 页面识别。 + +不动游戏状态 (建议配 ``--no-launch``), 30 秒验证 "设备 → 截图 → 识别" 全链路。 +也是新 case 的最小模板: 复制本文件, 改 DESC 和 run() 即可。 +""" + +from __future__ import annotations + +from typing import Any + + +# 一句话描述 (--list 时显示) +DESC = '链路自检: 连接 + 截图 + 页面识别 (建议 --no-launch)' + + +def run(rt: Any) -> bool: + """执行链路自检步骤。""" + from autowsgr.ui.page import get_current_page + + # 步骤1: 截图 (失败时框架自动截图存证并返回 rt.FAILED) + screen = rt.action('截图', rt.ctx.ctrl.screenshot) + if screen is rt.FAILED: + return False + + # 步骤2: 识别当前页面 + page = rt.action('识别当前页面', get_current_page, screen) + if page is rt.FAILED: + return False + + rt.note(f'当前页面: {page}') + return True diff --git a/tools/e2e/framework.py b/tools/e2e/framework.py new file mode 100644 index 00000000..4e2e02e5 --- /dev/null +++ b/tools/e2e/framework.py @@ -0,0 +1,381 @@ +"""E2E 快速实机验证框架 — 基座 (E2ERunner)。 + +算法说明: +1. 目标: 把 "连接设备 → 启动游戏 → 执行操作 → 判定结果" 的重复劳动收敛成一个 + 基座, 验证脚本 (case) 只写业务步骤本身, 实现 "加一个文件 = 多一个可跑的实机 + 验证"。 +2. 初始化: 走生产同款 Launcher 流程 (load_config → setup_logger → connect → + ensure_ready), 保证验证环境与 GUI / examples 脚本运行时行为一致; + --no-launch 可跳过游戏就绪 (纯只读验证), --with-ocr 决定是否初始化 OCR。 +3. 步骤执行: rt.action(label, fn) 执行任意函数并计时; 正常时原样返回 fn 的返回 + 值, 抛异常时记为失败、自动截图存证并返回 FAILED 哨兵, case 据此短路退出。 +4. 断言: rt.check(label, fn) 把返回值当 bool 判定, 用于次数 / 状态核对。 +5. 汇总: finalize(overall) 汇总所有步骤的通过 / 失败, 结合 case 整体返回值 + 给出进程退出码 (0 = 全部通过), 供终端与 CI 直接判断。 +""" + +from __future__ import annotations + +# 处理 Windows GBK 编码兼容性 (中文输出在默认代码页下可能乱码) +import sys +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + + +try: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') +except Exception: # noqa: S110 + pass # reconfigure 不可用时继续使用默认编码 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 数据结构 +# ═══════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class StepRec: + """单条步骤记录 (action / check 各记一条)。""" + + label: str # 步骤描述 (中文, 显示给用户) + ok: bool # 是否通过 (action: 是否抛异常; check: 断言结果) + duration_ms: int = 0 # 耗时 (毫秒) + error: str | None = None # 异常信息 (失败时) + + +@dataclass +class RunnerState: + """E2ERunner 的步骤累计状态。""" + + steps: list[StepRec] = field(default_factory=list) + + @property + def failed(self) -> int: + """失败步骤数。""" + return sum(1 for s in self.steps if not s.ok) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 基座 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class E2ERunner: + """单个 E2E case 的执行基座。 + + 职责: 设备连接、游戏就绪、步骤计时与记录、失败截图、汇总与退出码。 + case 只需调用 :meth:`action` / :meth:`check` / :meth:`note` 组织自己的步骤。 + """ + + # 失败哨兵: action 抛异常时返回它 (区别于业务返回值 None/False/空列表) + FAILED = object() + + def __init__( + self, + case_name: str, + case_args: Any, + *, + serial: str | None = None, + debug: bool = False, + no_launch: bool = False, + preserve_state: bool = False, + with_ocr: bool = False, + fast_ocr: bool = False, + ) -> None: + self.case_name = case_name + self.args = case_args # case 自己的参数 namespace (run.py 解析后传入) + self.serial = serial # ADB 序列号; None 时用 usersettings.yaml 配置 + self.debug = debug # True 时日志级别 DEBUG + self.no_launch = no_launch # True 时跳过游戏就绪 (纯只读验证) + self.with_ocr = with_ocr # True 时初始化 OCR 引擎 (编队识别等需要) + self.fast_ocr = fast_ocr # True 时仅在本次 E2E 内切换 CPU FastOCR + self.preserve_state = preserve_state + self.state = RunnerState() + self.ctx: Any = None # GameContext (prepare() 成功后可用) + self._launcher: Any = None + self._connected = False + self._cleanup_done = False + self._recovery_in_progress = False + # 每次运行独立目录: logs/e2e_tools//<时间戳> + stamp = datetime.now().astimezone().strftime('%Y%m%d_%H%M%S') + self.log_dir = Path('logs/e2e_tools') / case_name / stamp + + # ── 初始化 ───────────────────────────────────────────────────── + + def prepare(self) -> bool: + """连接设备并准备游戏环境。 + + 流程与生产 launch() 对齐: 读配置 → 初始化日志 → 连接设备 → 游戏就绪。 + ``no_launch=True`` 时只连接不启动游戏 (截图/页面识别等只读验证)。 + """ + from autowsgr.infra.logger import setup_logger + from autowsgr.scheduler.launcher import Launcher + + launcher = Launcher() + # Keep the handle before connect so a partially connected controller is + # still released when connection or context construction fails. + self._launcher = launcher + cfg = launcher.load_config() + + if self.fast_ocr: + ocr_cfg = cfg.ocr.model_copy(update={'enhanced_ship_ocr': True}) + launcher.set_config(cfg.model_copy(update={'ocr': ocr_cfg})) + + # 日志目录/级别以本次运行为准, 通道配置沿用 usersettings.yaml + channels = cfg.log.effective_channels or None + setup_logger( + log_dir=self.log_dir, + level='DEBUG' if self.debug else 'INFO', + save_images=True, + channels=channels, + ) + + # 命令行指定 serial 时覆盖配置 + if self.serial is not None: + emu = cfg.emulator.model_copy(update={'serial': self.serial}) + launcher.set_config(cfg.model_copy(update={'emulator': emu})) + + # 连接设备 + try: + launcher.connect() + self._connected = True + serial = launcher.config.emulator.serial or 'auto' + res = launcher.ctrl.resolution + print(f' [OK] 设备已连接: {serial} {res[0]}x{res[1]}') + except Exception as exc: + self._record('连接设备', ok=False, error=str(exc)) + print(f' [FAIL] 设备连接失败: {exc}') + return False + + try: + # 组装 GameContext (与 testing/ops 的 launch_for_test 同款流程) + from autowsgr.context import GameContext + + if self.with_ocr: + self.ctx = launcher.build_context() # 含 OCR 引擎 + else: + self.ctx = GameContext(ctrl=launcher.ctrl, config=launcher.config, ocr=None) + + if self.preserve_state: + self.note('保留设备当前游戏状态,由测试用例自行识别入口') + return True + + if self.no_launch: + # 显式只读模式: 不启动/导航游戏, 供截图和页面识别诊断使用。 + return True + + # 所有正常 E2E 都从同一个业务初始化入口开始,避免 OCR 分支 + # 与普通分支产生不同的首页/浮层状态。 + self._initialize_game() + except Exception as exc: + self._record('初始化游戏', ok=False, error=str(exc)) + print(f' [FAIL] 游戏初始化失败: {exc}') + self._error_screenshot('prepare') + return False + return True + + # ── 步骤 API (case 调用) ─────────────────────────────────────── + + def action(self, label: str, fn: Any, /, *args: Any, **kwargs: Any) -> Any: + """执行一个操作步骤。 + + 正常返回 fn 的返回值; 抛异常时记录失败、自动截图、返回 ``rt.FAILED``。 + case 约定写法:: + + results = rt.action('跑常规战', run_normal_fight_from_yaml, ctx, '1-1') + if results is rt.FAILED: + return False + """ + t0 = time.monotonic() + try: + if not self._ensure_game_running(): + raise RuntimeError('游戏进程已退出且重新初始化失败') + value = fn(*args, **kwargs) + # 操作期间进程可能被外部关闭。此处只恢复运行环境,不重放 + # 已经执行过的业务函数,避免点击类操作被重复提交。 + if not self._ensure_game_running(): + raise RuntimeError('操作后游戏进程已退出且重新初始化失败') + except Exception as exc: + self._recover_after_error() + self._record(label, ok=False, error=str(exc), t0=t0) + print(f' [FAIL] {label}: {exc}') + self._error_screenshot(label) + return self.FAILED + self._record(label, ok=True, t0=t0) + print(f' [OK] {label} ({self._ms_since(t0)}ms)') + return value + + def check(self, label: str, fn: Any, /, *args: Any, **kwargs: Any) -> bool: + """断言步骤: 把 fn 返回值当 bool 判定, 打印 PASS/FAIL。""" + t0 = time.monotonic() + try: + if not self._ensure_game_running(): + raise RuntimeError('游戏进程已退出且重新初始化失败') + passed = bool(fn(*args, **kwargs)) + error: str | None = None + if not self._ensure_game_running(): + passed = False + error = '检查后游戏进程已退出且重新初始化失败' + except Exception as exc: + self._recover_after_error() + passed = False + error = str(exc) + self._record(label, ok=passed, error=error, t0=t0) + mark = 'PASS' if passed else 'FAIL' + suffix = f': {error}' if error else '' + print(f' [{mark}] {label}{suffix}') + return passed + + def note(self, msg: str) -> None: + """打印一条说明信息 (不计入步骤, 不影响判定)。""" + print(f' [i] {msg}') + + # ── 兜底与汇总 ───────────────────────────────────────────────── + + def unexpected(self, exc: Exception) -> None: + """case 主体自身抛出的未捕获异常兜底 (记为一条失败步骤)。""" + self._record(f'case 异常 ({type(exc).__name__})', ok=False, error=str(exc)) + print(f' [FAIL] case 异常: {exc}') + self._error_screenshot('case_crash') + + def finalize(self, *, overall: bool = True) -> int: + """先收口游戏生命周期,再打印汇总并返回进程退出码。""" + self.cleanup() + total = len(self.state.steps) + failed = self.state.failed + print() + print('═' * 68) + for i, s in enumerate(self.state.steps, 1): + mark = 'OK ' if s.ok else 'FAIL' + dur = f'{s.duration_ms}ms' if s.duration_ms else '' + err = f' ← {s.error}' if s.error else '' + print(f' [{mark}] [{i:02d}] {s.label} {dur}{err}') + verdict = 'PASS' if (failed == 0 and overall) else 'FAIL' + print() + print(f' {self.case_name}: {total} 步, 失败 {failed} 步 → {verdict}') + print(f' 日志目录: {self.log_dir.resolve()}') + print('═' * 68) + return 0 if verdict == 'PASS' else 1 + + def cleanup(self) -> None: + """无论 case 结果如何,尝试回主页并释放设备连接。""" + if self._cleanup_done: + return + self._cleanup_done = True + + if self.ctx is not None and not self.no_launch and not self.preserve_state: + t0 = time.monotonic() + try: + self._initialize_game() + except Exception as exc: + self._record('清理: 回到主页面', ok=False, error=str(exc), t0=t0) + print(f' [FAIL] 清理: 回到主页面: {exc}') + self._error_screenshot('cleanup') + else: + self._record('清理: 回到主页面', ok=True, t0=t0) + print(f' [OK] 清理: 回到主页面 ({self._ms_since(t0)}ms)') + + if self._launcher is None or not self._connected: + return + try: + self._launcher.ctrl.disconnect() + self._connected = False + except Exception as exc: + self._record('清理: 断开设备', ok=False, error=str(exc)) + print(f' [FAIL] 清理: 断开设备: {exc}') + + # ── 游戏生命周期 ───────────────────────────────────────────── + + def _game_package(self) -> str: + """返回当前配置对应的 Android 包名。""" + if self.ctx is None: + raise RuntimeError('GameContext 尚未构造') + return self.ctx.config.account.package_name + + def _initialize_game(self) -> None: + """调用当前启动器的就绪入口,保证终态为首页待机。""" + if self.ctx is None: + raise RuntimeError('GameContext 尚未构造') + if self._launcher is None: + raise RuntimeError('Launcher 尚未构造') + self._launcher.ensure_ready(self.ctx) + + def _ensure_game_running(self) -> bool: + """检测游戏是否仍在运行;异常退出时只重新初始化一次。""" + if self.no_launch or self.ctx is None: + return True + try: + running = self.ctx.ctrl.is_app_running(self._game_package()) + except Exception as exc: + self.note(f'无法检查游戏进程: {exc}') + return False + if running or self._recovery_in_progress: + return running + + self._recovery_in_progress = True + try: + self.note('检测到游戏进程异常退出,重新初始化') + self._initialize_game() + except Exception as exc: + self.note(f'重新初始化失败: {exc}') + return False + else: + return True + finally: + self._recovery_in_progress = False + + def _recover_after_error(self) -> None: + """在步骤异常后尝试恢复已退出的游戏进程。""" + if self.no_launch or self.ctx is None or self._recovery_in_progress: + return + try: + self._ensure_game_running() + except Exception as recovery_error: + self.note(f'异常后恢复失败: {recovery_error}') + + # ── 内部工具 ─────────────────────────────────────────────────── + + @staticmethod + def _ms_since(t0: float) -> int: + return int((time.monotonic() - t0) * 1000) + + def _record( + self, + label: str, + *, + ok: bool, + error: str | None = None, + t0: float | None = None, + ) -> None: + self.state.steps.append( + StepRec( + label=label, + ok=ok, + duration_ms=self._ms_since(t0) if t0 is not None else 0, + error=error, + ) + ) + + def _error_screenshot(self, label: str) -> None: + """失败时自动截图存证 (截图本身失败则静默跳过)。""" + if self.ctx is None or self.ctx.ctrl is None: + return + try: + from autowsgr.infra import save_image + + screen = self.ctx.ctrl.screenshot() + safe_label = ''.join( + char if char.isascii() and (char.isalnum() or char in '._-') else '_' + for char in label + ) + tag = f'e2e_fail_{safe_label[:40]}' + path = save_image(screen, tag=tag) + if path: + print(f' 失败截图: {path}') + except Exception: # noqa: S110 + pass diff --git a/tools/e2e/run.py b/tools/e2e/run.py new file mode 100644 index 00000000..bf5f8c31 --- /dev/null +++ b/tools/e2e/run.py @@ -0,0 +1,220 @@ +"""E2E 快速实机验证入口 — case 发现、参数路由、执行与退出码。 + +用法:: + + # 列出全部可用 case + python tools/e2e/run.py --list + + # 链路自检 (只连接 + 截图, 不动游戏状态) + python tools/e2e/run.py screenshot --no-launch + + # 跑一次常规战 (编队识别需要 OCR) + python tools/e2e/run.py normal_fight --with-ocr --plan 1-1 --times 1 + + # 指定设备与调试日志 + python tools/e2e/run.py --serial 127.0.0.1:16384 --debug screenshot + +参数顺序约定: 全局参数可放在 case 名之前或之后, case 参数跟随 case。 +全局参数: --serial SERIAL / --debug / --no-launch / --with-ocr / --fast-ocr / --list。 +其余参数由 case 自己的 add_arguments(parser) 定义并解析 (见 cases/ 示例)。 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from typing import Any + + +# 处理 Windows GBK 编码兼容性 +try: + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') +except Exception: # noqa: S110 + pass + +# 保证仓库根可导入 (直接 python tools/e2e/run.py 运行时 sys.path[0] 是 tools/e2e) +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +_CASES_DIR = Path(__file__).parent / 'cases' + + +# ═══════════════════════════════════════════════════════════════════════════════ +# case 发现与加载 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def load_cases() -> dict[str, Any]: + """扫描 cases/ 目录, 加载所有带 run() 的验证脚本。 + + case 约定 (见 cases/ 内示例): + - 必须定义 ``run(rt) -> bool``: 步骤主体, 返回整体判定; + - 可选 ``DESC: str``: 一句话描述, --list 时显示; + - 可选 ``add_arguments(parser)``: 定义 case 专属命令行参数。 + """ + cases: dict[str, Any] = {} + if not _CASES_DIR.exists(): + return cases + for py in sorted(_CASES_DIR.glob('*.py')): + if py.name.startswith('_'): + continue + spec = importlib.util.spec_from_file_location(f'tools.e2e.cases.{py.stem}', py) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if hasattr(mod, 'run'): + cases[py.stem] = mod + return cases + + +def print_case_list(cases: dict[str, Any]) -> None: + """打印全部 case 及其描述。""" + print() + print('═' * 68) + print(' 可用 E2E 验证 case') + print('═' * 68) + if not cases: + print(f' (cases 目录为空: {_CASES_DIR})') + for name, mod in cases.items(): + desc = getattr(mod, 'DESC', '') + print(f' {name:24s} {desc}') + print() + print(' 运行: python tools/e2e/run.py [全局参数在前, case 参数在后]') + print(' 全局: --serial SERIAL / --debug / --no-launch / --preserve-state / --with-ocr') + print('═' * 68) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 参数切分: [全局参数] [case参数] +# ═══════════════════════════════════════════════════════════════════════════════ + + +def split_argv(argv: list[str]) -> tuple[list[str], str | None, list[str]]: + """把 argv 切成 (全局参数, case 名, case 参数)。 + + 全局参数可出现在 case 名之前或之后; ``--serial`` 带一个值; + 第一个非 ``-`` 开头的 token 视为 case 名, 其余非全局 token 归 case 参数。 + """ + global_flags = { + '--debug', + '--fast-ocr', + '--list', + '--no-launch', + '--preserve-state', + '--with-ocr', + } + global_args: list[str] = [] + case_name: str | None = None + case_args: list[str] = [] + i = 0 + while i < len(argv): + tok = argv[i] + if tok == '--serial' and i + 1 < len(argv): + global_args.extend(argv[i : i + 2]) + i += 2 # --serial 及其值 + continue + if tok in global_flags: + global_args.append(tok) + i += 1 + continue + if case_name is None and not tok.startswith('-'): + case_name = tok + elif case_name is None: + global_args.append(tok) + else: + case_args.append(tok) + i += 1 + return global_args, case_name, case_args + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 主流程 +# ═══════════════════════════════════════════════════════════════════════════════ + + +def main() -> int: + global_argv, case_name, rest = split_argv(sys.argv[1:]) + + cases = load_cases() + + # --list 或未指定 case: 打印列表后退出 + if '--list' in global_argv or case_name is None: + print_case_list(cases) + return 0 + + mod = cases.get(case_name) + if mod is None: + print(f'未知 case: {case_name}') + print_case_list(cases) + return 2 + + # 全局参数 + gp = argparse.ArgumentParser(add_help=False) + gp.add_argument('--serial', default=None, help='ADB 设备序列号 (默认用配置)') + gp.add_argument('--debug', action='store_true', help='DEBUG 日志') + gp.add_argument('--no-launch', action='store_true', help='跳过游戏就绪 (只读验证)') + gp.add_argument( + '--preserve-state', + action='store_true', + help='连接后保留设备当前页面,不自动回到首页', + ) + gp.add_argument('--with-ocr', action='store_true', help='初始化 OCR 引擎') + gp.add_argument('--fast-ocr', action='store_true', help='本次运行使用 CPU FastOCR') + g = gp.parse_args(global_argv) + + # case 参数 (由 case 自己定义; 未定义 add_arguments 时 rest 必须为空) + cp = argparse.ArgumentParser( + prog=f'e2e {case_name}', + description=getattr(mod, 'DESC', ''), + ) + if hasattr(mod, 'add_arguments'): + mod.add_arguments(cp) + case_args = cp.parse_args(rest) + + print() + print('═' * 68) + print(f' E2E: {case_name} — {getattr(mod, "DESC", "")}') + print('═' * 68) + print(f' 设备: {g.serial or "自动检测 (usersettings.yaml)"}') + print( + f' 模式: {"只读 (跳过游戏就绪)" if g.no_launch else "完整 (游戏就绪)"}' + f'{" + 保留当前状态" if g.preserve_state else ""}' + f'{" + OCR" if g.with_ocr else ""}{" + FastOCR" if g.fast_ocr else ""}' + ) + + # 执行 + from tools.e2e.framework import E2ERunner + + rt = E2ERunner( + case_name, + case_args, + serial=g.serial, + debug=g.debug, + no_launch=g.no_launch, + preserve_state=g.preserve_state, + with_ocr=g.with_ocr, + fast_ocr=g.fast_ocr, + ) + if not rt.prepare(): + return rt.finalize(overall=False) + overall = False + try: + overall = bool(mod.run(rt)) + except SystemExit: + # finalize() 位于 finally,保证显式退出也执行回主页/断开连接。 + raise + except Exception as exc: + rt.unexpected(exc) + finally: + rt.finalize(overall=overall) + return 0 if overall and rt.state.failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/e2e_chapter_nav.py b/tools/e2e_chapter_nav.py new file mode 100644 index 00000000..e39449af --- /dev/null +++ b/tools/e2e_chapter_nav.py @@ -0,0 +1,200 @@ +"""E2E 实机测试: 侧边栏章节导航。 + +运行前请确保: +1. MuMu 模拟器已启动, adb 可连到 emulator-5554 +2. 游戏官服已登录, 停在任意页面 (脚本自动确保就绪并进入地图页) +3. 本脚本在 AutoWSGR 项目根目录下用 .venv 运行 + +测试流程: +- 进入地图页, OCR 识别当前章 C0 +- navigate_to_chapter(C0 - 3 if C0 > 3 else C0 + 3) → 向中间方向跨 3 章 (每次单步±1, 实际跳 3 次) +- OCR 确认目标章到达 +- navigate_to_chapter(1) → 跳回第 1 章 +- navigate_to_chapter(10) → 跳到第 10 章 +- navigate_to_chapter(1) → 再回到 1 章 (大跨度 9 章校验) +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +# 确保 autowsgr 包可导入 +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from loguru import logger + +from autowsgr.infra import ( + AccountConfig, + EmulatorConfig, + LogConfig, + UserConfig, +) +from autowsgr.infra.config import OCRConfig +from autowsgr.types import GameAPP, PageName +from autowsgr.scheduler.launcher import Launcher +from autowsgr.ops.navigate import goto_page +from autowsgr.ui.map.panels.sortie import SortiePanelMixin +from autowsgr.ui.map.base import BaseMapPage + + +def build_cfg() -> UserConfig: + """构造最小运行配置。""" + log_cfg = LogConfig(level='DEBUG') + # 不持久化 DEBUG 到磁盘, 节省 IO + return UserConfig( + emulator=EmulatorConfig(serial='emulator-5554'), + account=AccountConfig(game_app=GameAPP.official), + ocr=OCRConfig(), + log=log_cfg, + ) + + +def main() -> int: + cfg = build_cfg() + launcher = Launcher() + launcher.set_config(cfg) + # 手动触发日志初始化 (load_config 会做, 这里 set_config 后手动调) + from autowsgr.infra.logger import setup_logger # noqa: WPS433 + setup_logger(cfg.log.dir, cfg.log.level, save_images=True, + channels=cfg.log.effective_channels) + + logger.info('[E2E] 连接模拟器 emulator-5554 ...') + launcher.connect() + ctx = launcher.build_context() + launcher.ensure_ready(ctx) # 游戏停在主页面 + logger.info('[E2E] 游戏就绪') + + # ── 导航到地图页 ── + logger.info('[E2E] 进入地图页') + goto_page(ctx, PageName.MAP) + + # 构造面板控制器实例: 同时继承 SortiePanelMixin + BaseMapPage, + # 这样 enter_sortie 用到的 ensure_panel / switch_panel 才不会 AttributeError。 + # SortiePanelMixin 本身也继承 BaseMapPage, 实际是同一祖先, MRO 不会冲突。 + class _Map(SortiePanelMixin, BaseMapPage): + def __init__(self, ctx): + # BaseMapPage.__init__ 会绑定 self._ctx / self._ctrl / self._ocr + BaseMapPage.__init__(self, ctx) + + page = _Map(ctx) + + # ── 先 OCR 读一次当前章 ── + info = None + for _ in range(3): + info = page.recognize_map(ctx.ctrl.screenshot(), ctx.ocr) + if info is not None: + break + time.sleep(0.5) + if info is None: + logger.error('[E2E] 地图标题 OCR 失败, 无法继续') + return 2 + cur_chapter = info.chapter + logger.info('[E2E] 初始章: 第 {} 章 ({}-{} {})', cur_chapter, info.chapter, info.map_num, info.name) + + results: list[tuple[int, int, bool]] = [] # (from, to, ok) + + # ── Test 1: 向中间跨 3 章 (相对短路径) ── + target1 = cur_chapter - 3 if cur_chapter > 5 else cur_chapter + 3 + if 1 <= target1 <= 10 and target1 != cur_chapter: + logger.info('[E2E] === Test 1: 跳 {} → {} 章 ==='.format(cur_chapter, target1)) + result = page.navigate_to_chapter(target1) + ok = result == target1 + logger.info('[E2E] Test 1 结果: 期望={} 实际={} {}'.format(target1, result, '✓' if ok else '✗')) + results.append((cur_chapter, target1, ok)) + cur_chapter = result or target1 + else: + logger.warning('[E2E] 跳过 Test 1 (边界章)') + + # ── Test 2: 跳回第 1 章 (若已在1章则跳过) ── + if cur_chapter != 1: + logger.info('[E2E] === Test 2: 跳 {} → 第 1 章 ==='.format(cur_chapter)) + result = page.navigate_to_chapter(1) + ok = result == 1 + logger.info('[E2E] Test 2 结果: 期望=1 实际={} {}'.format(result, '✓' if ok else '✗')) + results.append((cur_chapter, 1, ok)) + cur_chapter = result or 1 + else: + logger.warning('[E2E] 跳过 Test 2 (已在第1章)') + + # ── Test 3: 跳 1 → 10 章 (大跨度 +9) ── + if cur_chapter == 1: + logger.info('[E2E] === Test 3: 跳 1 → 第 10 章 (大跨度 +9) ===') + result = page.navigate_to_chapter(10) + ok = result == 10 + logger.info('[E2E] Test 3 结果: 期望=10 实际={} {}'.format(result, '✓' if ok else '✗')) + results.append((1, 10, ok)) + cur_chapter = result or 10 + + # ── Test 4: 跳 10 → 1 章 (大跨度 -9) ── + if cur_chapter == 10: + logger.info('[E2E] === Test 4: 跳 10 → 第 1 章 (大跨度 -9) ===') + result = page.navigate_to_chapter(1) + ok = result == 1 + logger.info('[E2E] Test 4 结果: 期望=1 实际={} {}'.format(result, '✓' if ok else '✗')) + results.append((10, 1, ok)) + cur_chapter = result or 1 + + # ── Test 5: 1 → 2 → 3 单步校验 ── + if cur_chapter == 1: + logger.info('[E2E] === Test 5: 1→2→3 连续单步 ===') + r2 = page.navigate_to_chapter(2) + r3 = page.navigate_to_chapter(3) if r2 == 2 else None + ok = r2 == 2 and r3 == 3 + logger.info('[E2E] Test 5 结果: 期望=2,3 实际={},{} {}'.format(r2, r3, '✓' if ok else '✗')) + results.append((1, 3, ok)) + + # ── Test 6: 真实进入 2-1 出征准备页(全链路 章→关卡→准备页) ── + logger.info('[E2E] === Test 6: enter_sortie(2, 1) 真实点击进入出征准备页 ===') + ok6 = False + try: + # 先确保停在 第 2 章 (若从 Test 5 的 3 出发则回退一步) + page.navigate_to_chapter(2) + # 进入出征准备页 (内部会 navigate_to_map(1) + CLICK_ENTER_SORTIE) + page.enter_sortie(2, 1) + # 不抛 NavigationError 就是成功。立即返回地图页, 避免留在准备页污染后续。 + logger.info('[E2E] Test 6: enter_sortie 无异常, 尝试返回地图页') + ctx.ctrl.press_back() + time.sleep(1.2) + from autowsgr.ui.utils import wait_for_page # noqa: WPS433 + wait_for_page(ctx.ctrl, BaseMapPage.is_current_page, timeout=8.0, + source='出征准备返回', target=PageName.MAP) + # 回到地图页后再次 OCR, 确认当前就是 2-1 (说明真的停在目标图而不是乱跳) + info6 = None + for _ in range(3): + info6 = page.recognize_map(ctx.ctrl.screenshot(), ctx.ocr) + if info6 is not None: + break + time.sleep(0.3) + if info6 is not None and info6.chapter == 2 and info6.map_num == 1: + ok6 = True + logger.info('[E2E] Test 6 结果: 2-1→出征准备页 ✓,返回后 OCR={}-{} {} ✓'.format( + info6.chapter, info6.map_num, info6.name)) + else: + logger.warning('[E2E] Test 6 返回后章/节校验异常: {}'.format(info6)) + except Exception as e: # noqa: BLE001 + logger.exception('[E2E] Test 6 失败: {}'.format(e)) + # 失败时强制兜底返回, 不污染后续流程或其他脚本 + try: + ctx.ctrl.press_back() + time.sleep(1.0) + except Exception: + pass + results.append((2, 1, ok6)) + + # ── 汇总 ── + total = len(results) + passed = sum(1 for _, _, ok in results if ok) + failed = total - passed + logger.info('=' * 60) + logger.info('[E2E] 汇总: 共 {} 项, 通过 {}, 失败 {}'.format(total, passed, failed)) + for i, (frm, to, ok) in enumerate(results, 1): + logger.info(' Test {}: {}→{} {}'.format(i, frm, to, '✓' if ok else '✗')) + logger.info('=' * 60) + return 0 if failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/ocr_change_fleet_e2e/conftest.py b/tools/ocr_change_fleet_e2e/conftest.py new file mode 100644 index 00000000..4b73bb51 --- /dev/null +++ b/tools/ocr_change_fleet_e2e/conftest.py @@ -0,0 +1,83 @@ +"""出征准备页换船 e2e pytest 的共享 fixture 与命令行参数。 + +提供三个命令行参数: + +- ``--config``: 用户配置文件路径 (YAML),每次运行可加载不同配置 +- ``--fleet``: 要更换的舰队编号 (1-4) +- ``--ships``: 目标舰船名列表,逗号分隔 (最多 6 个) + +adb 校验在 :func:`game_ctx` fixture 中完成:adb 可执行文件不可用 +或没有在线设备时,测试以 ``pytest.skip`` 跳过,不误报失败。 +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +import pytest + +from autowsgr.emulator.detector import list_adb_devices +from autowsgr.infra import ConfigManager + +if TYPE_CHECKING: + from autowsgr.context import GameContext + + +def pytest_addoption(parser: pytest.Parser) -> None: + """注册换船 e2e 测试的命令行参数。""" + group = parser.getgroup('change_fleet_e2e') + group.addoption( + '--config', + default=None, + help='用户配置文件路径 (YAML);缺省时自动检测 usersettings.yaml', + ) + group.addoption('--fleet', type=int, default=1, help='要更换的舰队编号 (1-4)') + group.addoption( + '--ships', + default='U-47,U-96', + help='目标舰船名列表,逗号分隔 (最多 6 个)', + ) + + +def _adb_ready() -> tuple[bool, str]: + """校验 adb 可用性:可执行文件可用且至少有一台在线设备。""" + try: + devices = list_adb_devices() + except Exception as exc: # noqa: BLE001 + return False, f'adb 校验失败: {exc}' + online = sorted(serial for serial, status in devices if status == 'device') + if not online: + return False, '未检测到在线设备 (adb devices 中无 device 状态)' + return True, f'在线设备: {", ".join(online)}' + + +@pytest.fixture(scope='module') +def game_ctx(request: pytest.FixtureRequest) -> Iterator[GameContext]: + """按指定 yaml 加载配置、连接模拟器并启动游戏;adb 未就绪时跳过测试。""" + config_path: str | None = request.config.getoption('--config') + ConfigManager.load(config_path) # 提前校验 yaml 可正常加载 + + ok, message = _adb_ready() + if not ok: + pytest.skip(message) + + from autowsgr.scheduler.launcher import launch + + ctx = launch(config_path) + yield ctx + ctx.ctrl.disconnect() + + +@pytest.fixture +def fleet_id(request: pytest.FixtureRequest) -> int: + """目标舰队编号。""" + return int(request.config.getoption('--fleet')) + + +@pytest.fixture +def ships(request: pytest.FixtureRequest) -> list[str | None]: + """目标舰船名列表 (按槽位 0-5,缺省补 None)。""" + raw: str = request.config.getoption('--ships') + names = [name.strip() or None for name in raw.split(',')] + return (names + [None] * 6)[:6] diff --git a/tools/ocr_change_fleet_e2e/test_change_fleet_e2e.py b/tools/ocr_change_fleet_e2e/test_change_fleet_e2e.py new file mode 100644 index 00000000..a7d94d25 --- /dev/null +++ b/tools/ocr_change_fleet_e2e/test_change_fleet_e2e.py @@ -0,0 +1,69 @@ +"""出征准备页换船 e2e 测试 — 主页 → 出征 → 编队 → 换船 → 主页。 + +调试 OCR 专用工具 (本周 OCR 调优主用),需连接真实模拟器。 + +运行:: + + pytest tools/ocr_change_fleet_e2e/test_change_fleet_e2e.py + pytest tools/ocr_change_fleet_e2e/test_change_fleet_e2e.py \\ + --config configs/emulator_a.yaml --fleet 2 --ships "U-47,U-96" + +每次运行可通过 ``--config`` 加载不同的 yaml 配置 (模拟器 / OCR / 舰船别名等)。 + +前置条件: + - 本机 adb 可用 + - 至少一台模拟器在线 + - 目标舰船存在于玩家船坞 + +设备未就绪时由 ``game_ctx`` fixture 以 ``pytest.skip`` 跳过,不误报失败。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from autowsgr.ops import goto_page +from autowsgr.types import PageName +from autowsgr.ui.battle.preparation import BattlePreparationPage +from autowsgr.ui.main_page import MainPage +from autowsgr.ui.page import get_current_page + +if TYPE_CHECKING: + from collections.abc import Sequence + + from autowsgr.context import GameContext + + +def _current_page(ctx: GameContext) -> str: + """返回当前页面名,用于断言失败时的诊断信息。""" + return get_current_page(ctx.ctrl.screenshot()) + + +def test_main_prep_change_fleet_back_main( + game_ctx: GameContext, + fleet_id: int, + ships: Sequence[str | None], +) -> None: + """主页 → 出征 → 编队 → 换船 → 主页 的完整往返。""" + ctx = game_ctx + + # 1. 主页 (launch 后应已就位) + assert MainPage.is_current_page(ctx.ctrl.screenshot()), ( + f'启动后应位于主页面,实际: {_current_page(ctx)}' + ) + + # 2. 出征 → 编队 (出征准备页) + goto_page(ctx, PageName.BATTLE_PREP) + assert BattlePreparationPage.is_current_page(ctx.ctrl.screenshot()), ( + f'应位于出征准备页,实际: {_current_page(ctx)}' + ) + + # 3. 换船 (按槽位放入目标舰船) + page = BattlePreparationPage(ctx) + assert page.change_fleet(fleet_id, ships), '换船流程应成功' + + # 4. 返回主页 + goto_page(ctx, PageName.MAIN) + assert MainPage.is_current_page(ctx.ctrl.screenshot()), ( + f'应返回主页面,实际: {_current_page(ctx)}' + ) diff --git a/tools/ocr_crop_tool.py b/tools/ocr_crop_tool.py new file mode 100644 index 00000000..b38ee6fd --- /dev/null +++ b/tools/ocr_crop_tool.py @@ -0,0 +1,626 @@ +"""编队页与船池页 OCR 区域采集工具。 + +算法流程: +1. 通过随工具附带的 ADB 连接指定模拟器。 +2. 使用 ``exec-out screencap -p`` 获取无损 PNG 截图。 +3. 编队页按生产环境的六个槽位坐标裁切舰名、等级和舰种。 +4. 编队空槽沿用生产环境的血条颜色探测规则过滤。 +5. 船池页先缩放到 1280x720,再调用原生 DLL 定位名称横带。 +6. 每条名称横带按固定七列映射到具体船卡。 +7. 通过名称横带中的亮色文字过滤船池空卡。 +8. 船池等级和舰种使用生产环境相同的相对偏移坐标。 +9. 每个有效区域输出 1X、2X、3X、4X 四种图片。 +10. 放大统一使用 INTER_CUBIC,保持 OCR 测试输入一致。 +11. 时间戳模式按秒创建一次性目录。 +12. 汇总模式按日期复用目录,并为每次采集追加序号。 +13. 原始 ADB 截图保存在时间戳主目录。 +14. team、pool 下始终创建 name、level、type 三类目录。 +15. 所有输出均为 PNG,不依赖本机 Python 或 OCR 模型。 +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shutil +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import cv2 +import numpy as np + + +if TYPE_CHECKING: + from argparse import Namespace + + +REFERENCE_WIDTH = 1280 +REFERENCE_HEIGHT = 720 +POOL_LIST_WIDTH = 1048 +DEFAULT_SERIAL = '127.0.0.1:16384' +SCALES = (1, 2, 3, 4) + +# 编队页坐标与 autowsgr/ui/battle/constants.py 保持一致。 +TEAM_SLOT_CENTERS = (0.1146, 0.2292, 0.3438, 0.4583, 0.5729, 0.6875) +TEAM_NAME_Y_RANGE = (435 / REFERENCE_HEIGHT, 462 / REFERENCE_HEIGHT) +TEAM_NAME_HALF_WIDTH = (TEAM_SLOT_CENTERS[1] - TEAM_SLOT_CENTERS[0]) / 2 +TEAM_BLOOD_PROBES = { + slot: (center, 0.691) for slot, center in enumerate(TEAM_SLOT_CENTERS, start=1) +} +TEAM_LEVEL_CROPS = { + 1: (0.0496, 0.6104, 0.0941, 0.6319), + 2: (0.1640, 0.6104, 0.2085, 0.6319), + 3: (0.2785, 0.6104, 0.3230, 0.6319), + 4: (0.3930, 0.6104, 0.4375, 0.6319), + 5: (0.5074, 0.6104, 0.5519, 0.6319), + 6: (0.6219, 0.6104, 0.6664, 0.6319), +} +TEAM_TYPE_CROPS = { + 1: (0.0594, 0.6458, 0.0953, 0.6861), + 2: (0.1738, 0.6458, 0.2098, 0.6861), + 3: (0.2883, 0.6458, 0.3242, 0.6861), + 4: (0.4027, 0.6458, 0.4387, 0.6861), + 5: (0.5172, 0.6458, 0.5531, 0.6861), + 6: (0.6316, 0.6458, 0.6676, 0.6861), +} + +# RGB 血条颜色。离哪个颜色最近,就使用哪个状态。 +TEAM_BLOOD_COLORS = { + 'normal': (75, 168, 118), + 'moderate': (246, 184, 51), + 'severe': (171, 18, 17), + 'severe_prepare': (230, 58, 89), + 'empty_blood': (58, 60, 62), + 'no_ship': (43, 87, 112), +} + +# 船池卡片中心来自 1280x720 船池页面。DLL 仅负责定位名称横带的纵坐标。 +POOL_CARD_CENTERS_X = (122, 262, 402, 542, 682, 822, 962) +POOL_NAME_HALF_WIDTH = 70 +POOL_NAME_TEXT_HALF_WIDTH = 58 +POOL_NAME_BRIGHT_THRESHOLD = 180 +POOL_NAME_BRIGHT_RATIO = 0.015 +POOL_LEVEL_OFFSETS = (-62, -38, -2, -20) +POOL_TYPE_OFFSETS = (-62, -59, -13, -34.5) + +PoolRowLocator = Callable[[np.ndarray], Sequence[Sequence[int]]] + + +class CropToolError(RuntimeError): + """可直接展示给用户的工具错误。""" + + +@dataclass(frozen=True, slots=True) +class CropBox: + """以原图像素表示的裁切框,允许半像素边界。""" + + left: float + top: float + right: float + bottom: float + + +@dataclass(frozen=True, slots=True) +class CaptureTarget: + """本次采集的输出目录与文件名序号。""" + + root: Path + suffix: str + + +def _runtime_root() -> Path: + """返回源码目录或打包后 ``main.exe`` 所在目录。""" + if getattr(sys, 'frozen', False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def _bundle_root() -> Path: + """返回 PyInstaller 数据目录;源码运行时退回项目根目录。""" + bundled = getattr(sys, '_MEIPASS', None) + return Path(bundled).resolve() if bundled else _runtime_root() + + +def _config_path() -> Path: + return _runtime_root() / 'config.json' + + +def _default_output_root() -> Path: + return _runtime_root() / 'output' + + +def _read_saved_serial() -> str: + """读取上次连接成功的设备地址,失败时使用默认模拟器。""" + path = _config_path() + if not path.is_file(): + return DEFAULT_SERIAL + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return DEFAULT_SERIAL + serial = data.get('serial') + return serial.strip() if isinstance(serial, str) and serial.strip() else DEFAULT_SERIAL + + +def _save_serial(serial: str) -> None: + """记录最后成功连接的设备,供后续截图命令复用。""" + path = _config_path() + path.write_text( + json.dumps({'serial': serial}, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + + +def _resolve_adb_path(explicit: str | None = None) -> Path: + """优先使用打包内置 ADB,其次使用项目环境或系统 ADB。""" + candidates = [ + Path(explicit).expanduser() if explicit else None, + _bundle_root() / 'adb' / 'adb.exe', + _runtime_root() / 'adb' / 'adb.exe', + Path(r'C:\ShiinaKuroko\01.Project\AutoWSGR-GUI\adb\adb.exe'), + Path(sys.executable).resolve().parent + / 'Lib' + / 'site-packages' + / 'adbutils' + / 'binaries' + / 'adb.exe', + ] + for candidate in candidates: + if candidate is not None and candidate.is_file(): + return candidate.resolve() + + system_adb = shutil.which('adb') + if system_adb: + return Path(system_adb).resolve() + raise CropToolError('未找到 adb.exe,请使用打包后的完整工具目录') + + +def _run_adb( + adb_path: Path, + arguments: Sequence[str], + *, + binary: bool = False, +) -> subprocess.CompletedProcess[Any]: + """执行一次 ADB 命令,不弹出额外控制台窗口。""" + creation_flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + return subprocess.run( # noqa: S603 - ADB 路径由工具目录或受信任配置解析。 + [str(adb_path), *arguments], + check=False, + capture_output=True, + text=not binary, + encoding=None if binary else 'utf-8', + errors=None if binary else 'replace', + creationflags=creation_flags, + ) + + +def connect_device(adb_path: Path, serial: str) -> None: + """连接设备并确认设备状态为 ``device``。""" + result = _run_adb(adb_path, ['connect', serial]) + message = (result.stdout or result.stderr).strip() + if result.returncode != 0: + raise CropToolError(f'ADB 连接失败:{message or "未知错误"}') + + state = _run_adb(adb_path, ['-s', serial, 'get-state']) + if state.returncode != 0 or state.stdout.strip() != 'device': + detail = (state.stdout or state.stderr).strip() + raise CropToolError(f'设备未就绪:{detail or serial}') + _save_serial(serial) + print(f'ADB 已连接:{serial}({message})') + + +def capture_adb_screen(adb_path: Path, serial: str) -> np.ndarray: + """通过 ADB 获取无损 PNG,并解码为 OpenCV BGR 图像。""" + connect_device(adb_path, serial) + result = _run_adb(adb_path, ['-s', serial, 'exec-out', 'screencap', '-p'], binary=True) + if result.returncode != 0 or not result.stdout: + detail = result.stderr.decode(errors='replace').strip() if result.stderr else '' + raise CropToolError(f'ADB 截图失败:{detail or "没有返回图片"}') + + encoded = np.frombuffer(result.stdout, dtype=np.uint8) + screen = cv2.imdecode(encoded, cv2.IMREAD_COLOR) + if screen is None: + raise CropToolError('ADB 截图不是有效的 PNG 图片') + return screen + + +def _relative_box( + screen: np.ndarray, + region: tuple[float, float, float, float], +) -> CropBox: + """把相对坐标转换为原图像素坐标。""" + height, width = screen.shape[:2] + left, top, right, bottom = region + return CropBox(left * width, top * height, right * width, bottom * height) + + +def _offset_box( + screen: np.ndarray, + center_x: float, + center_y: float, + offsets: tuple[float, float, float, float], +) -> CropBox: + """按 1280x720 基准偏移生成船池单卡裁切框。""" + height, width = screen.shape[:2] + left, top, right, bottom = offsets + return CropBox( + center_x + left * width / REFERENCE_WIDTH, + center_y + top * height / REFERENCE_HEIGHT, + center_x + right * width / REFERENCE_WIDTH, + center_y + bottom * height / REFERENCE_HEIGHT, + ) + + +def _crop_scaled(screen: np.ndarray, box: CropBox, scale: int) -> np.ndarray: + """按生产 OCR 的先取整、放大、再裁内边界流程生成图片。""" + height, width = screen.shape[:2] + left = max(0.0, min(float(width), box.left)) + right = max(0.0, min(float(width), box.right)) + top = max(0.0, min(float(height), box.top)) + bottom = max(0.0, min(float(height), box.bottom)) + if right <= left or bottom <= top: + raise CropToolError('裁切坐标超出截图范围') + + source_left = math.floor(left) + source_right = math.ceil(right) + source_top = math.floor(top) + source_bottom = math.ceil(bottom) + source = screen[source_top:source_bottom, source_left:source_right] + if source.size == 0: + raise CropToolError('裁切结果为空') + + enlarged = source + if scale != 1: + enlarged = cv2.resize( + source, + None, + fx=scale, + fy=scale, + interpolation=cv2.INTER_CUBIC, + ) + inner_left = round((left - source_left) * scale) + inner_right = round((right - source_left) * scale) + inner_top = round((top - source_top) * scale) + inner_bottom = round((bottom - source_top) * scale) + return enlarged[inner_top:inner_bottom, inner_left:inner_right].copy() + + +def _write_png(path: Path, image: np.ndarray) -> None: + """使用 ``tofile`` 保存,兼容 Windows 中文路径。""" + path.parent.mkdir(parents=True, exist_ok=True) + success, encoded = cv2.imencode('.png', image) + if not success: + raise CropToolError(f'图片编码失败:{path.name}') + encoded.tofile(str(path)) + + +def _save_region_variants( + screen: np.ndarray, + box: CropBox, + directory: Path, + stem: str, + suffix: str, +) -> int: + """保存一个区域的 1X 至 4X 图片。""" + for scale in SCALES: + filename = f'{stem}-{scale}X{suffix}.png' + _write_png(directory / filename, _crop_scaled(screen, box, scale)) + return len(SCALES) + + +def _nearest_blood_state(pixel_bgr: np.ndarray) -> str: + """按 RGB 欧氏距离判断编队槽位是否为蓝色空位。""" + pixel_rgb = np.asarray(pixel_bgr[::-1], dtype=np.float32) + return min( + TEAM_BLOOD_COLORS, + key=lambda state: float( + np.linalg.norm(pixel_rgb - np.asarray(TEAM_BLOOD_COLORS[state], dtype=np.float32)) + ), + ) + + +def _team_slot_occupied(screen: np.ndarray, slot: int) -> bool: + """复用准备页血条探测点过滤空编队槽位。""" + height, width = screen.shape[:2] + x_ratio, y_ratio = TEAM_BLOOD_PROBES[slot] + x = max(0, min(width - 1, round(x_ratio * width))) + y = max(0, min(height - 1, round(y_ratio * height))) + return _nearest_blood_state(screen[y, x]) != 'no_ship' + + +def _team_name_box(screen: np.ndarray, slot: int) -> CropBox: + """返回单个编队槽位的舰名横带。""" + center = TEAM_SLOT_CENTERS[slot - 1] + top, bottom = TEAM_NAME_Y_RANGE + return _relative_box( + screen, + ( + center - TEAM_NAME_HALF_WIDTH, + top, + center + TEAM_NAME_HALF_WIDTH, + bottom, + ), + ) + + +def crop_team( + screen: np.ndarray, + output_root: Path, + suffix: str = '', +) -> tuple[int, int]: + """裁切编队页,返回有效槽位数和保存图片数。""" + valid_slots = 0 + saved_images = 0 + for slot in range(1, 7): + if not _team_slot_occupied(screen, slot): + continue + + valid_slots += 1 + boxes = { + 'name': _team_name_box(screen, slot), + 'level': _relative_box(screen, TEAM_LEVEL_CROPS[slot]), + 'type': _relative_box(screen, TEAM_TYPE_CROPS[slot]), + } + for kind, box in boxes.items(): + saved_images += _save_region_variants( + screen, + box, + output_root / 'team' / kind, + f'Team-slot-{slot}-{kind}', + suffix, + ) + return valid_slots, saved_images + + +def _load_pool_locator() -> PoolRowLocator: + """延迟导入原生 DLL,便于显示清晰的缺失依赖错误。""" + try: + from autowsgr_native.recognition import locate + except ImportError as exc: + raise CropToolError('缺少船池定位 DLL,请使用打包后的完整工具目录') from exc + return locate + + +def _locate_pool_rows( + screen: np.ndarray, + locator: PoolRowLocator | None = None, +) -> list[tuple[int, int]]: + """把截图转换为 DLL 所需的 1280x720 格式并定位名称横带。""" + legacy = cv2.resize(screen, (REFERENCE_WIDTH, REFERENCE_HEIGHT)) + list_area = np.ascontiguousarray(legacy[:, :POOL_LIST_WIDTH]) + raw_rows = (locator or _load_pool_locator())(list_area) + rows: list[tuple[int, int]] = [] + for raw_row in raw_rows: + if len(raw_row) < 2: + continue + top, bottom = int(raw_row[0]), int(raw_row[1]) + if 0 <= top < bottom <= REFERENCE_HEIGHT: + rows.append((top, bottom)) + return rows + + +def _pool_card_occupied( + legacy_screen: np.ndarray, + center_x: int, + row: tuple[int, int], +) -> bool: + """通过名称横带中的白色文字比例过滤船池空卡。""" + top, bottom = row + left = max(0, center_x - POOL_NAME_TEXT_HALF_WIDTH) + right = min(POOL_LIST_WIDTH, center_x + POOL_NAME_TEXT_HALF_WIDTH) + crop = legacy_screen[top:bottom, left:right] + if crop.size == 0: + return False + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) + return float(np.mean(gray > POOL_NAME_BRIGHT_THRESHOLD)) >= POOL_NAME_BRIGHT_RATIO + + +def _pool_name_box( + screen: np.ndarray, + center_x_ref: int, + row: tuple[int, int], +) -> CropBox: + """把 DLL 名称横带裁成一张船卡对应的名称区域。""" + height, width = screen.shape[:2] + top, bottom = row + return CropBox( + (center_x_ref - POOL_NAME_HALF_WIDTH) * width / REFERENCE_WIDTH, + top * height / REFERENCE_HEIGHT, + (center_x_ref + POOL_NAME_HALF_WIDTH) * width / REFERENCE_WIDTH, + bottom * height / REFERENCE_HEIGHT, + ) + + +def crop_pool( + screen: np.ndarray, + output_root: Path, + suffix: str = '', + *, + locator: PoolRowLocator | None = None, +) -> tuple[int, int]: + """裁切船池页,返回有效卡片数和保存图片数。""" + rows = _locate_pool_rows(screen, locator) + if not rows: + raise CropToolError('DLL 未定位到船池名称条,请确认当前处于船池页面') + + legacy = cv2.resize(screen, (REFERENCE_WIDTH, REFERENCE_HEIGHT)) + height, width = screen.shape[:2] + valid_cards = 0 + saved_images = 0 + for row_index, row in enumerate(rows): + row_center_ref = (row[0] + row[1]) / 2 + center_y = round(row_center_ref * height / REFERENCE_HEIGHT) + for column_index, center_x_ref in enumerate(POOL_CARD_CENTERS_X): + if not _pool_card_occupied(legacy, center_x_ref, row): + continue + + slot = row_index * len(POOL_CARD_CENTERS_X) + column_index + 1 + center_x = round(center_x_ref * width / REFERENCE_WIDTH) + valid_cards += 1 + boxes = { + 'name': _pool_name_box(screen, center_x_ref, row), + 'level': _offset_box(screen, center_x, center_y, POOL_LEVEL_OFFSETS), + 'type': _offset_box(screen, center_x, center_y, POOL_TYPE_OFFSETS), + } + for kind, box in boxes.items(): + saved_images += _save_region_variants( + screen, + box, + output_root / 'pool' / kind, + f'Pool-slot-{slot}-{kind}', + suffix, + ) + if valid_cards == 0: + raise CropToolError('DLL 找到名称条,但没有检测到有效船卡') + return valid_cards, saved_images + + +def _ensure_output_tree(root: Path) -> None: + """创建固定的 team/pool/name/level/type 目录结构。""" + for page in ('team', 'pool'): + for kind in ('name', 'level', 'type'): + (root / page / kind).mkdir(parents=True, exist_ok=True) + + +def _next_capture_sequence(root: Path, page: str) -> int: + """根据原始截图数量生成不会覆盖旧数据的采集序号。""" + return len(list(root.glob(f'adb-{page}*.png'))) + 1 + + +def prepare_capture_target( + output_root: Path, + mode: str, + page: str, + now: datetime | None = None, +) -> CaptureTarget: + """创建时间戳目录,并计算汇总模式的文件名后缀。""" + current = now or datetime.now().astimezone() + if mode == 'timestamp': + root = output_root / current.strftime('%Y%m%d-%H%M%S') + root.mkdir(parents=True, exist_ok=True) + _ensure_output_tree(root) + sequence = _next_capture_sequence(root, page) + suffix = '' if sequence == 1 else f'-{sequence:03d}' + return CaptureTarget(root=root, suffix=suffix) + + root = output_root / current.strftime('%Y%m%d') + root.mkdir(parents=True, exist_ok=True) + _ensure_output_tree(root) + sequence = _next_capture_sequence(root, page) + return CaptureTarget(root=root, suffix=f'-{sequence:03d}') + + +def _save_source_screen( + screen: np.ndarray, + target: CaptureTarget, + page: str, +) -> Path: + """把原始 ADB 截图保存到时间戳主目录。""" + path = target.root / f'adb-{page}{target.suffix}.png' + _write_png(path, screen) + return path + + +def _normalize_mode(value: str) -> str: + """允许用户使用 A/B 简写两种归档模式。""" + normalized = value.lower() + aliases = { + 'a': 'timestamp', + 'timestamp': 'timestamp', + 'b': 'summary', + 'summary': 'summary', + } + if normalized not in aliases: + raise argparse.ArgumentTypeError('模式只能是 A/timestamp 或 B/summary') + return aliases[normalized] + + +def _add_capture_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '--mode', + '-m', + type=_normalize_mode, + default='timestamp', + help='A/timestamp:按秒建目录;B/summary:按日期汇总(默认 A)', + ) + parser.add_argument('--serial', help='设备地址;默认使用最后连接的设备') + parser.add_argument('--output', type=Path, help='输出根目录;默认是工具旁的 output') + parser.add_argument('--raw-only', action='store_true', help='只保存原始截图,不生成 ROI 裁切图') + parser.add_argument('--adb-path', help=argparse.SUPPRESS) + + +def build_parser() -> argparse.ArgumentParser: + """创建 ``main.exe`` 命令行解析器。""" + parser = argparse.ArgumentParser( + prog='main', + description='AutoWSGR 编队页/船池页 OCR 裁切工具', + ) + subparsers = parser.add_subparsers(dest='command') + + adb_parser = subparsers.add_parser('adb', help='连接模拟器') + adb_parser.add_argument('serial', nargs='?', help=f'设备地址,默认 {DEFAULT_SERIAL}') + adb_parser.add_argument('--adb-path', help=argparse.SUPPRESS) + + team_parser = subparsers.add_parser('team', help='采集编队页') + _add_capture_arguments(team_parser) + + pool_parser = subparsers.add_parser('pool', help='采集船池页') + _add_capture_arguments(pool_parser) + return parser + + +def _run_capture(args: Namespace) -> int: + """执行 team 或 pool 采集命令。""" + adb_path = _resolve_adb_path(args.adb_path) + serial = args.serial or _read_saved_serial() + screen = capture_adb_screen(adb_path, serial) + output_root = (args.output or _default_output_root()).expanduser().resolve() + target = prepare_capture_target(output_root, args.mode, args.command) + source_path = _save_source_screen(screen, target, args.command) + if args.raw_only: + print(f'原始截图:{source_path}') + print(f'输出目录:{target.root}') + return 0 + + if args.command == 'team': + valid_items, saved_images = crop_team(screen, target.root, target.suffix) + item_name = '有效编队槽位' + else: + valid_items, saved_images = crop_pool(screen, target.root, target.suffix) + item_name = '有效船池卡片' + + print(f'原始截图:{source_path}') + print(f'{item_name}:{valid_items}') + print(f'裁切图片:{saved_images}') + print(f'输出目录:{target.root}') + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + """命令行入口。""" + parser = build_parser() + args = parser.parse_args(argv) + if args.command is None: + parser.print_help() + return 0 + + try: + if args.command == 'adb': + adb_path = _resolve_adb_path(args.adb_path) + connect_device(adb_path, args.serial or _read_saved_serial()) + return 0 + return _run_capture(args) + except CropToolError as exc: + print(f'错误:{exc}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/ocr_crop_tool_README.txt b/tools/ocr_crop_tool_README.txt new file mode 100644 index 00000000..a3075886 --- /dev/null +++ b/tools/ocr_crop_tool_README.txt @@ -0,0 +1,92 @@ +AutoWSGR OCR 截图裁切工具 +========================== + +本工具无需安装 Python,也不会下载 OCR 模型。 +请保留 main.exe 和 _internal 文件夹的相对位置,不要单独移动 main.exe。 + +一、打开工具 +------------ + +双击 start-tool.cmd,或者在本目录打开 PowerShell/CMD。 + +二、连接模拟器 +-------------- + +连接默认模拟器: + + main.exe adb + +默认地址为: + + 127.0.0.1:16384 + +连接其他模拟器: + + main.exe adb 127.0.0.1:5555 + +连接成功后会记住设备地址,后续 team、pool 命令直接使用该设备。 + +三、采集编队页 +-------------- + +先让游戏停留在编队页面,然后执行: + + main.exe team + +四、采集船池页 +-------------- + +先让游戏停留在船池选择页面,然后执行: + + main.exe pool + +五、归档模式 +-------------- + +模式 A:每次按秒创建新目录,默认模式。 + + main.exe team --mode A + main.exe pool --mode A + +模式 B:当天结果汇总到同一个日期目录,不覆盖已有图片。 + + main.exe team --mode B + main.exe pool --mode B + +六、输出位置 +-------------- + +默认保存在工具旁的 output 文件夹: + + output/ + └── 时间戳/ + ├── adb-team.png 或 adb-pool.png + ├── team/ + │ ├── name/ + │ ├── level/ + │ └── type/ + └── pool/ + ├── name/ + ├── level/ + └── type/ + +每个有效槽位会保存 1X、2X、3X、4X 四种 PNG 图片。 +空编队槽位和船池空卡不会保存。 + +七、自定义输出目录 +------------------ + + main.exe team --output D:\ocr-samples + main.exe pool --mode B --output D:\ocr-samples + +八、常见错误 +------------ + +“设备未就绪”: +确认模拟器已启动,然后重新执行 main.exe adb。 + +“DLL 未定位到船池名称条”: +确认当前页面是船池选择页面,并且页面滑动已经停止。 + +“没有检测到有效船卡”: +确认船池页面中存在可见舰船卡片。 diff --git a/tools/ocr_crop_tool_start.cmd b/tools/ocr_crop_tool_start.cmd new file mode 100644 index 00000000..7b126d60 --- /dev/null +++ b/tools/ocr_crop_tool_start.cmd @@ -0,0 +1,8 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +main.exe --help +echo. +echo 当前目录已经切换到工具目录,可以输入 main.exe adb、main.exe team 或 main.exe pool。 +echo. +%COMSPEC% /k diff --git a/usersettings.yaml b/usersettings.yaml index f36a895c..04560acc 100644 --- a/usersettings.yaml +++ b/usersettings.yaml @@ -125,20 +125,37 @@ daily_automation: # 提示:决战舰队不要选择名字会滚动的舰船,否则容易导致识别错误 # ═══════════════════════════════════════════ decisive_battle: - chapter: 1 # 决战章节 (1-6) + chapter: 6 # 决战章节 (1-6) decisive_rounds: 1 # 决战连续执行轮数 level1: # 一级舰队 - - 青波鱼 - - U-1206 - U-47 - - 射水鱼 + - U-1405 + - U-1206 + - U-2540 + - U-81 - U-96 - - 鹦鹉螺 level2: # 二级舰队 - - U-1405 + - U-505 + - 射水鱼 + - 大青花鱼 - M-296 + - 鹦鹉螺 + - S-49 + - IIIA + - K-21 + - U-441 + - 潜甲 + - 潜乙 + - 伊-201 - 伊-25 - - "351" + - 鲃鱼 + - 伊-400 + - 激流 + - U-4501 + - U-459 + - U-14 + - U-35 + - K1 flagship_priority: # 旗舰优先级队列 - U-1405 - U-47