From 3dce225a7f45cbe6e9b784829ee82405aa654c86 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:46:33 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(combat):=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E5=AD=98=E8=88=B0=E9=98=9F=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/combat/plan.py | 49 +++++++++++++++++++++++ testing/combat/test_combat.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 0f2f4b25..0ba1bf87 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -236,6 +236,8 @@ class CombatPlan: 出征舰队编号。 fleet: 舰队成员名单(换船用)。 + fleet_presets: + GUI 整理后的舰队预设列表。 repair_mode: 修理策略。 fight_condition: @@ -259,6 +261,7 @@ class CombatPlan: """ fleet_id: int = 1 fleet: list[str] | None = None + fleet_presets: list[dict[str, Any]] | None = None repair_mode: RepairMode | list[RepairMode] = RepairMode.severe_damage fight_condition: FightCondition = FightCondition.aim selected_nodes: list[str] = field(default_factory=list) @@ -293,6 +296,50 @@ def is_selected_node(self, node: str) -> bool: return True # 未配置白名单 = 全部允许 return node in self.selected_nodes + @classmethod + def _parse_fleet_presets(cls, raw: Any) -> list[dict[str, Any]] | None: + """解析舰队预设,并整理名称、舰名和候选列表。""" + if raw is None: + return None + if not isinstance(raw, list): + raise TypeError('fleet_presets 必须是列表') + + presets: list[dict[str, Any]] = [] + for raw_preset in raw: + name = cls._trim_text(raw_preset.get('name', '')) + ships = [ + cls._normalize_preset_slot(raw_slot) + for raw_slot in raw_preset.get('ships', []) + ] + presets.append({'name': name, 'ships': ships}) + return presets + + @staticmethod + def _trim_text(value: Any) -> Any: + """删除字符串首尾空格,其他类型保持不变。""" + return value.strip() if isinstance(value, str) else value + + @classmethod + def _normalize_preset_slot(cls, raw_slot: Any) -> Any: + """整理一个舰队槽位,并按填写顺序去除重复候选。""" + if isinstance(raw_slot, str): + return raw_slot.strip() + if not isinstance(raw_slot, dict): + return raw_slot + + result = { + key: cls._trim_text(value) + for key, value in raw_slot.items() + } + candidates = result.get('candidates') + if isinstance(candidates, list): + candidates = [ + cls._trim_text(candidate) + for candidate in candidates + ] + result['candidates'] = list(dict.fromkeys(candidates)) + return result + @classmethod def from_yaml(cls, path: str | Path) -> CombatPlan: from autowsgr.infra.config_compat import ( @@ -320,6 +367,7 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: map_id, entrance = parse_map_value(data.get('map', 1)) fleet_id = data.get('fleet_id', 1) fleet = data.get('fleet') + fleet_presets = cls._parse_fleet_presets(data.get('fleet_presets')) fight_condition = FightCondition(data.get('fight_condition', 4)) selected_nodes = data.get('selected_nodes', []) @@ -360,6 +408,7 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: entrance=entrance, fleet_id=fleet_id, fleet=fleet, + fleet_presets=fleet_presets, repair_mode=repair_mode, fight_condition=fight_condition, selected_nodes=selected_nodes, diff --git a/testing/combat/test_combat.py b/testing/combat/test_combat.py index f2113694..31e9e03f 100644 --- a/testing/combat/test_combat.py +++ b/testing/combat/test_combat.py @@ -433,6 +433,81 @@ def test_with_enemy_rules(self): assert result.result == RuleResult.RETREAT +class TestFleetPresetsParsing: + """fleet_presets 解析测试。""" + + def test_missing_presets_keeps_legacy_fleet(self): + """未配置预设时,旧 fleet 字段保持不变。""" + plan = CombatPlan.from_dict({'fleet': ['飞龙', 'U-1206']}) + assert plan.fleet == ['飞龙', 'U-1206'] + assert plan.fleet_presets is None + + @pytest.mark.parametrize('invalid_presets', [{}, 'preset', 1]) + def test_presets_must_be_list(self, invalid_presets: object): + """fleet_presets 顶层必须使用列表。""" + with pytest.raises(TypeError, match='fleet_presets 必须是列表'): + CombatPlan.from_dict({'fleet_presets': invalid_presets}) + + def test_empty_presets_is_preserved(self): + """空列表由上层决定业务含义。""" + plan = CombatPlan.from_dict({'fleet_presets': []}) + assert plan.fleet_presets == [] + + def test_preset_content_is_normalized(self): + """整理名称和槽位,并按顺序去除重复候选。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': ' 测试舰队 ', + 'ships': [ + ' 飞龙·改 ', + { + 'candidates': [' 岛风 ', '黑潮', '岛风'], + 'ship_type': ' dd ', + 'min_level': 100, + }, + ], + }, + ], + }, + ) + + assert plan.fleet_presets == [ + { + 'name': '测试舰队', + 'ships': [ + '飞龙·改', + { + 'candidates': ['岛风', '黑潮'], + 'ship_type': 'dd', + 'min_level': 100, + }, + ], + }, + ] + + def test_unknown_slot_fields_are_preserved(self): + """解析阶段不删除槽位中的其他字段。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'ships': [ + {'name': '契卡洛夫', 'max_level': 110}, + ], + }, + ], + }, + ) + assert plan.fleet_presets == [ + { + 'name': '', + 'ships': [{'name': '契卡洛夫', 'max_level': 110}], + }, + ] + + # ═══════════════════════════════════════════════════════════════════════════════ # actions.py 测试 # ═══════════════════════════════════════════════════════════════════════════════ From 5f95716ad587d5c49e605518dd94e2ca4158e0a2 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:46:49 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs(feat):=20=E6=95=B4=E7=90=86=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8A=9F=E8=83=BD=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-decisive-fleet-change-algorithm-switch.md | 51 +++ docs/features/feat-ocr-ship-name-matching.md | 105 +++++++ .../feat-refactor-decisive-fight-module.md | 43 +++ docs/features/feat-release-summary.md | 149 +++++++++ docs/features/feat-smart-fleet-change.md | 156 +++++++++ .../features/feat-unified-combat-plan-yaml.md | 296 ++++++++++++++++++ 6 files changed, 800 insertions(+) create mode 100644 docs/features/feat-decisive-fleet-change-algorithm-switch.md create mode 100644 docs/features/feat-ocr-ship-name-matching.md create mode 100644 docs/features/feat-refactor-decisive-fight-module.md create mode 100644 docs/features/feat-release-summary.md create mode 100644 docs/features/feat-smart-fleet-change.md create mode 100644 docs/features/feat-unified-combat-plan-yaml.md diff --git a/docs/features/feat-decisive-fleet-change-algorithm-switch.md b/docs/features/feat-decisive-fleet-change-algorithm-switch.md new file mode 100644 index 00000000..79a08317 --- /dev/null +++ b/docs/features/feat-decisive-fleet-change-algorithm-switch.md @@ -0,0 +1,51 @@ +# 决战换船算法开关 + +## 状态 + +代码已完成,待决战实机验证。 + +## 目标 + +新的换船算法先在常规出征中使用,决战是否启用由独立开关控制。 +关闭开关不会停止决战,而是继续使用原有的决战换船和 OCR 流程。 + +## 配置 + +```yaml +decisive_battle: + use_new_fleet_change_algorithm: false +``` + +- `false`:默认值,使用原有决战换船流程。 +- `true`:决战使用新的换船算法。 + +API 请求支持同名字段。 + +## 原有决战 OCR 流程 + +1. 准备页调用 `detect_fleet()`,从六个槽位的舰名区域识别当前舰队。 +2. 不传 `expected_names`,不使用新算法提供的目标舰名上下文。 +3. 决战选船页没有搜索框,通过 DLL 定位舰船行,再使用 OCR 匹配并点击。 +4. 完成成员替换和顺序调整后,再次 OCR 验证结果。 + +`ship_name_match_confidence` 是独立的 OCR feat。该配置启用时, +原有决战流程仍会使用共享 OCR 模块中的置信度匹配。 + +## 代码改动 + +- `DecisiveConfig` 和决战 API 请求增加 `use_new_fleet_change_algorithm`。 +- `DecisiveBattlePreparationPage.change_fleet()` 根据开关选择算法。 +- `legacy_fleet_change.py` 保留原有决战换船流程。 +- 新换船算法本身不处理开关,避免常规出征受到影响。 + +## 验证 + +- 单元测试确认默认使用原有流程。 +- 单元测试确认开启后使用新算法。 +- 单元测试确认原有流程调用 OCR 时不传目标舰名上下文。 + +## TODO + +- 在决战环境中分别实测开关关闭和开启。 +- 实机确认旧流程的舰队识别、直接列表选船和顺序调整。 +- 决战入口独立重构见 `feat-refactor-decisive-fight-module.md`。 diff --git a/docs/features/feat-ocr-ship-name-matching.md b/docs/features/feat-ocr-ship-name-matching.md new file mode 100644 index 00000000..a6630cd2 --- /dev/null +++ b/docs/features/feat-ocr-ship-name-matching.md @@ -0,0 +1,105 @@ +# Feat:舰名 OCR 匹配调优 + +## 状态 + +- 日期:2026-08-01 +- 状态:已完成首版,开放 dev 测试 +- 范围:舰名 OCR、船池匹配和目标舰队上下文,不包含 YAML 格式。 + +## 为什么要改 + +原实现只使用 Levenshtein 编辑距离在完整船池中找最近舰名,存在以下问题: + +- `·`、`:`、`-` 和空格等符号识别不稳定,同一舰名可能因为标点不同而匹配失败。 +- 自定义舰名常表现为“基础舰名 + 后缀”,纯编辑距离容易拒绝正确基础舰名。 +- 长舰名可能被 OCR 截断,但短前缀直接匹配又会误伤大量一至三字舰名。 +- `Z1`、`Z16`、`Z17` 等短舰名相近,全船池匹配容易选错。 +- 智能换船已经知道目标队伍,但原检测无法利用这个上下文修正最后一个模糊结果。 + +## 匹配流程 + +```mermaid +flowchart TD + A[OCR 原始文字] --> B[应用通用文字补丁] + B --> C[去标点并统一大小写] + C --> D{船池关系} + D -->|完全一致| E[唯一候选直接命中] + D -->|基础舰名加后缀| F[最长基础舰名加置信度] + D -->|长舰名被截断| G[唯一长前缀加置信度] + D -->|没有前缀关系| H[原编辑距离匹配] + E --> I[槽位舰名] + F --> I + G --> I + H --> I + I --> J{已识别大部分目标且结果失败或重复} + J -->|是| K[从剩余目标中唯一补全] + J -->|否| L[保留船池结果] + + style E fill:#c8e6c9,color:#1a5e20 + style F fill:#bbdefb,color:#0d47a1 + style G fill:#bbdefb,color:#0d47a1 + style K fill:#fff3e0,color:#e65100 +``` + +## 具体改动 + +### 可配置置信度 + +- `OCRConfig` 新增 `ship_name_match_confidence`,默认值为 `0.65`,取值范围为 `0` 至 `1`。 +- `0` 表示关闭船池感知规则,继续使用原编辑距离逻辑。 +- `launcher.py` 创建 OCR 引擎时同步参数。 +- 启用后输出日志:`OCR置信度匹配机制加载(当前参数:0.65)`。 + +### 船池感知匹配 + +- 匹配前移除标点和空格,统一字母大小写,保留中文、字母和数字。 +- 归一化后完全一致时,只有唯一候选才接受。 +- OCR 文字以基础舰名开头时,按自定义后缀处理,基础舰名至少保留两个字符,并优先最长基础舰名。 +- 舰名以 OCR 文字开头时,按截断处理;OCR 至少保留四个字符,且共享前缀的候选只能有一个。 +- 置信度使用对称前缀 Dice:`2 × 公共前缀长度 ÷ (OCR 长度 + 舰名长度)`。 +- 同时符合自定义后缀和截断关系、候选不唯一或低于阈值时拒绝猜测。 +- 不存在可解释的前缀关系时,保留原编辑距离匹配,兼容旧行为。 +- 选船页比较目标舰名与 OCR 船池结果时复用同一套匹配规则。 + +### 目标舰队上下文 + +- `detect_fleet()` 支持传入 `expected_names`。 +- 完整船池必须先识别出目标队伍中除至多一艘外的其他成员,才启用上下文。 +- 只有船池匹配失败,或重复命中已被其他槽位占用的目标舰名时才补全。 +- 只从尚未占用的目标舰名中选择编辑距离 `2` 内唯一的最近项。 +- 智能换船使用该能力;普通检测、`event_fight.py`、`normal_fight.py` 和旧决战流程不传目标上下文。 + +### 实现精简 + +完全一致、自定义后缀和 OCR 截断原先拆成四个一次性辅助函数,现合并到一个船池匹配函数。 +行为保持不变,`ocr.py` 相对主分支的改动由约 `+219` 行减少到约 `+122` 行。 + +## 已解决 + +- 标点差异造成的同舰名匹配失败。 +- 可解释的自定义后缀和唯一长舰名截断。 +- 短舰名、共享前缀和归一化后重名时的盲目猜测。 +- `Z1` 系列在目标舰队大部分成员已确认时的最后一项补全。 +- 置信度参数从配置加载并在启动日志中可见。 + +## 未解决 + +- 目标上下文是带目标答案的补全,不是独立于目标的二次验证。 +- 上下文补全使用固定编辑距离 `2`,暂不读取 `ship_name_match_confidence`。 +- 非前缀型 OCR 错字仍依赖旧编辑距离,无法使用前缀置信度解释。 +- 自定义舰名、基础舰名和同舰别名尚未形成统一身份模型。 +- 本地补充的 `autowsgr/data/shipnames.yaml` 不进入本次提交。 + +## 验证 + +- OCR 基础与船池匹配测试:`71 passed`。 +- 881 艘船池的自定义后缀、阈值边界和截断场景:`8 passed`。 +- 准备页目标上下文和换船测试:`83 passed`。 +- Ruff、格式检查和 `git diff --check` 通过。 + +## TODO + +- 评估目标上下文是否与 `ship_name_match_confidence` 共用阈值。 +- 为目标上下文补充独立实机日志,确认误补全比例。 +- 统一基础舰名、自定义舰名和别名的身份判断。 +- 扩充非前缀错字、短舰名和归一化重名样本。 diff --git a/docs/features/feat-refactor-decisive-fight-module.md b/docs/features/feat-refactor-decisive-fight-module.md new file mode 100644 index 00000000..92e7f34b --- /dev/null +++ b/docs/features/feat-refactor-decisive-fight-module.md @@ -0,0 +1,43 @@ +# 决战模块入口重构 + +## 状态 + +TODO,暂不实施。 + +## 目标 + +将决战的作战入口从现有内部目录中整理出来,使其与 +`autowsgr/ops/normal_fight.py`、`autowsgr/ops/event_fight.py` +处于同一层级。 + +建议目标入口: + +```text +autowsgr/ops/decisive_fight.py +``` + +该入口只负责组织完整决战流程,具体的地图状态、舰队计算和节点处理 +仍可保留在独立的决战内部模块中。 + +## 原因 + +- 三种作战模式应有统一、清晰的外部调用入口。 +- 调度器和 API 不需要了解决战内部目录结构。 +- 决战换船、导航和战斗状态逻辑可以分别测试。 +- 后续替换决战换船算法时,不影响普通战和活动战入口。 + +## TODO + +- 盘点调度器、API 和测试中所有决战入口调用。 +- 定义与 `run_normal_fight()` 同层级的决战启动函数。 +- 将参数转换与流程编排集中到 `decisive_fight.py`。 +- 保持现有决战配置和 API 入参兼容。 +- 保留内部状态机、地图控制和舰队计算模块。 +- 增加旧入口兼容测试和完整决战回归。 +- 完成迁移后再删除旧入口,避免一次性改动过大。 + +## 本轮不做 + +- 不移动现有决战生产代码。 +- 不修改决战状态机。 +- 不把换船算法开关与本次架构重构混在一起。 diff --git a/docs/features/feat-release-summary.md b/docs/features/feat-release-summary.md new file mode 100644 index 00000000..47539125 --- /dev/null +++ b/docs/features/feat-release-summary.md @@ -0,0 +1,149 @@ +# 本轮智能换船与 OCR 功能总览 + +## 发布范围 + +本轮共保留 5 个专题 feat: + +| 数量 | 状态 | 专题 | +| --- | --- | --- | +| 3 | 已实现,进入 dev 测试 | OCR 舰名匹配、智能换船、决战算法开关 | +| 1 | 部分实现 | GUI 与后端作战计划 YAML 契约 | +| 1 | 仅记录 TODO | 决战模块独立入口重构 | + +原有专题文档全部保留,本文件只提供发布总览,不代替各专题的实现说明。 + +## 整体流程 + +```mermaid +flowchart LR + A[YAML 或 API 舰队规则] --> B[解析六个槽位] + B --> C[智能换船] + C --> D[选船页按舰名 舰种 等级筛选] + D --> E[OCR 识别最终舰队] + E --> F{验证通过} + F -->|是| G[进入战斗] + F -->|否| H[局部修正或停止] + I[决战算法开关] --> C + I --> J[旧决战换船流程] + + style C fill:#bbdefb,color:#0d47a1 + style E fill:#f3e5f5,color:#7b1fa2 + style G fill:#c8e6c9,color:#1a5e20 + style H fill:#ffcdd2,color:#8b1a1a + style I fill:#fff3e0,color:#e65100 +``` + +## 1. 舰名 OCR 匹配调优 + +文档:`feat-ocr-ship-name-matching.md` + +功能: + +- 忽略标点和大小写差异,保留中文、字母和数字。 +- 支持唯一的基础舰名、自定义后缀和长舰名截断关系。 +- 使用可配置置信度拒绝短舰名、歧义前缀和低可信匹配。 +- 智能换船可在大部分目标已确认后,用目标上下文补全最后一个模糊结果。 + +解决: + +- 标点、自定义后缀和 OCR 截断导致的舰名匹配失败。 +- `Z1` 系列等相近短舰名在目标舰队中的部分识别问题。 + +未解决: + +- 目标上下文不是独立二次验证。 +- 上下文仍使用固定编辑距离 `2`。 +- 基础舰名、自定义舰名和别名尚未统一身份。 + +## 2. 智能换船算法 + +文档:`feat-smart-fleet-change.md` + +功能: + +- 六个槽位分别使用自己的固定舰名或候选列表。 +- 使用回溯为槽位分配不同舰名,避免同舰名重复入队。 +- 首次完整对齐,失败后只修正错误槽位。 +- 1 队槽位 0 先替换后移除,避免舰队被清空。 +- 修复 `Lv.` 标签与等级数字分离、`110` 被识别为 `Il0` 等等级 OCR 问题。 +- 换船失败返回 `False`,作战入口立即停止。 + +解决: + +- 槽位候选被错误合并成全局候选。 +- `AB -> C` 时先移除导致一队为空。 +- `min_level=100` 时无法识别实际为 `110` 的舰船。 +- 验证失败后重复调整整支舰队。 +- 换船失败后仍带错误舰队出征。 + +未解决: + +- 已在队伍中的同名舰只比较舰名,尚未复核 `ship_type`、`min_level` 和 `max_level`。 +- 1 队先替换后移除尚未完成实机验证。 + +## 3. 决战换船算法开关 + +文档:`feat-decisive-fleet-change-algorithm-switch.md` + +功能: + +- 默认继续使用原决战换船流程。 +- 开启 `use_new_fleet_change_algorithm` 后使用智能换船。 +- YAML 配置和决战 API 使用同一个开关字段。 + +解决: + +- 新算法无法逐步开放给决战测试的问题。 +- 关闭新算法时决战无法继续使用旧流程的问题。 + +未解决: + +- 开关开启和关闭都需要完成决战整章实机回归。 + +## 4. 作战计划 YAML 契约 + +文档:`feat-unified-combat-plan-yaml.md` + +当前完成: + +- 后端读取并保存 `fleet_presets`。 +- 只做列表类型检查、字符串去空格和候选顺序去重。 +- 不在后端轮询多套 preset;一次出击只执行 GUI 或 API 选定的一套舰队。 + +未解决: + +- GUI、YAML parser 和 HTTP API 尚未共享同一份 Schema。 +- `priority`、`nation` 等 GUI 编排字段尚未统一转换。 +- 部分 API 字段仍存在“接受但未传入运行模型”的情况。 + +## 5. 决战模块入口重构 + +文档:`feat-refactor-decisive-fight-module.md` + +本轮只记录设计,不移动生产代码。目标是在后续将决战入口整理为 +`autowsgr/ops/decisive_fight.py`,与普通战和活动战入口平级。 + +## 发布前本地验证 + +- 准备页与智能换船单元测试:`83 passed`。 +- OCR 基础匹配测试:`71 passed`。 +- 881 艘船池场景测试:`8 passed`。 +- Ruff、格式检查和 `git diff --check` 通过。 + +测试代码仅用于本地验证,不进入本次个人分支提交。 + +## 后续 TODO + +1. 完成决战旧流程与新算法的整章实机回归。 +2. 实机验证 1 队槽位 0 的先替换后移除流程。 +3. 复核已有同名舰的舰种和等级条件。 +4. 统一目标上下文与全船池置信度规则。 +5. 完成 GUI、YAML 和 API 的共享 Schema。 +6. 将决战入口重构到 `autowsgr/ops/decisive_fight.py`。 + +## 本次提交边界 + +- 提交生产代码和 6 份 feat 文档。 +- 不提交 `testing/`、`.dbg/`、调试文档和本地测试计划。 +- 不提交本地临时修改的 `autowsgr/data/shipnames.yaml`。 +- 不提交本地 `usersettings.yaml`。 diff --git a/docs/features/feat-smart-fleet-change.md b/docs/features/feat-smart-fleet-change.md new file mode 100644 index 00000000..f644a0f9 --- /dev/null +++ b/docs/features/feat-smart-fleet-change.md @@ -0,0 +1,156 @@ +# 智能换船算法 + +## 状态 + +核心算法已实现,已完成非核心代码清理和重复逻辑精简。 + +## 目标 + +根据六个舰队槽位的规则完成换船,并保证: + +- 固定舰名和槽位候选都能使用。 +- 每个槽位只使用自己的候选。 +- 同一舰队不会选择两个标准舰名相同的舰船。 +- 1 队槽位 0 不会被清空。 +- 首次整体调整失败后,只修正错误槽位。 +- 换船结果经过 OCR 再次确认,失败时返回 `False`。 + +## 输入 + +每个槽位支持三种形式: + +```yaml +ships: + - 飞龙 + - candidates: [岛风, 黑潮] + search_name: 岛风 + ship_type: dd + min_level: 100 + max_level: 110 + - null +``` + +- 字符串:固定舰名。 +- 规则对象:按顺序尝试 `candidates`,并把舰种、等级等条件交给选船页面。 +- `null`:该槽位应为空。 + +不足六个槽位时在末尾补空位。 +超过六个槽位时只读取前六个槽位。 + +每次只接收并执行一套 `ships`。不会在第一套舰队失败后继续尝试其他 +preset,换船失败时直接停止当前作战流程。 + +## 当前流程 + +```mermaid +flowchart TD + A[读取六个槽位规则] --> B[整理舰名和候选] + B --> C{能否为各槽分配不同舰名} + C -->|否| D[报错停止] + C -->|是| E[OCR 识别当前舰队] + E --> F{当前舰队已满足目标} + F -->|是| G[返回成功] + F -->|否| H{第几次执行} + H -->|首次| I[完整成员对齐] + H -->|重试| J[只修正错误槽位] + I --> K[拖拽调整顺序] + J --> K + K --> L[OCR 验证最终舰队] + L -->|成功| G + L -->|失败且未超过两次重试| E + L -->|仍失败| M[返回失败] + + style G fill:#c8e6c9,color:#1a5e20 + style D fill:#ffcdd2,color:#8b1a1a + style M fill:#ffcdd2,color:#8b1a1a + style I fill:#bbdefb,color:#0d47a1 + style J fill:#fff3e0,color:#e65100 +``` + +## 核心改动 + +### 槽位候选 + +候选不再合并成全局船池。每个槽位只读取自己的 `candidates`, +并按填写顺序选择。 + +### 同名舰分配 + +换船前使用回溯分配六个槽位,确保不同槽位不会得到同一个标准舰名。 +如果候选无法组成不重复的舰队,直接报错,不进入选船页面。 + +### 完整对齐 + +第一次执行时: + +1. 保留当前舰队中已经满足目标的舰船。 +2. 先替换或补充缺少的舰船。 +3. 再从后往前移除多余舰船。 +4. 因槽位压缩导致缺员时再次补齐。 + +### 1 队槽位 0 + +1 队槽位 0 不能为空。执行 `AB -> C` 时先把槽位 0 的 `A` +直接替换成 `C`,然后移除 `B`,不会先把舰队清空。 + +### 局部修正 + +首次验证失败后不再整体重做。算法先找出错误槽位,只替换或移除这些槽位, +随后重新排序和验证,最多局部修正两次。 + +### 实际入队舰名 + +候选第一项不代表实际入队舰船。选船页面会返回实际选择结果, +算法用该结果更新目标和后续验证,不使用 `candidates[0]` 伪造舰名。 + +选船页面已经按舰名、`ship_type`、`min_level`、`max_level` 完成筛选时, +换船算法信任选船结果。最终 OCR 只确认舰名和槽位,不再二次识别舰种和等级。 + +### 等级 OCR + +- 等级 `110` 在 720P 下可能被识别为 `Il0`、`ll0`,允许两个数字易混淆字符。 +- `Lv.` 标签和等级数字被 OCR 拆成两个结果时,合并同一区域内的高置信度纯数字。 +- 没有识别到 `Lv.` 标签时,只接受大于等于 `100` 的三位等级,避免把其他数字当成等级。 + +该修复解决了选船页已经显示 `110`,但 `min_level=100` 仍被判断为无可用舰船的问题。 + +## 与其他 feat 的边界 + +- OCR 原文、模糊匹配和目标舰名上下文属于 + `feat-ocr-ship-name-matching.md`。 +- GUI 与后端 YAML 格式统一属于 + `feat-unified-combat-plan-yaml.md`。 +- 决战是否启用新算法属于 + `feat-decisive-fleet-change-algorithm-switch.md`。 +- 作战执行器只调用 `change_fleet()` 并处理成功或失败,不参与换船细节。 + +## 已删除的非核心代码 + +- `_report_fleet_debug()`、`_report_level_debug()` 及所有本地网络调试上报。 +- `change_fleet_with_fallback()` 多 preset 轮询。 +- 仅供测试读取的 `last_resolved_ship_names` 状态及对应测试。 +- 只测试多 preset fallback 的专项实机测试。 + +`expected_names` 已确定保留,具体启用条件和已知边界记录在 +`feat-ocr-ship-name-matching.md`。 + +## TODO + +- 已在队伍中的同名舰船只根据舰名判断,尚未重新确认 + `ship_type`、`min_level`、`max_level`。 +- 后续只处理上述“已有同名舰是否应触发重新选择”的问题。真正进入选船页面 + 并按约束选中后,不增加二次确认。 + +## 已完成验证 + +- 1 队 `AB -> C` 的替换顺序单元测试。 +- 槽位级候选和同名舰去重单元测试。 +- 固定舰名、候选和局部修正单元测试。 +- 突击者同名舰的 `cv`、`cvl` 舰种实机选择。 +- 决战旧流程和新算法的首次换船实机验证。 + +## 未完成验证 + +- 决战旧流程完整章节回归中途停止,尚未验证全链路结束。 +- 1 队 `AB -> C` 尚未完成实机验证。 +- 已在队伍中的同名舰是否符合舰种和等级条件。 diff --git a/docs/features/feat-unified-combat-plan-yaml.md b/docs/features/feat-unified-combat-plan-yaml.md new file mode 100644 index 00000000..0fdd1b97 --- /dev/null +++ b/docs/features/feat-unified-combat-plan-yaml.md @@ -0,0 +1,296 @@ +# Feat: 统一 GUI 与后端作战计划 YAML 契约 + +## 状态 + +- 日期:2026-08-01 +- 阶段:后端解析已收口,GUI 改造待办 +- 实现状态:暂停,择日继续 GUI 部分 +- 当前结论:`combat/plan.py` 只做列表类型校验和基础整理,严格格式约束留给 GUI + 与后续共享 Schema。 + +## 背景 + +当前存在三套并不完全一致的作战计划入参: + +1. AutoWSGR-GUI 保存和编辑的 YAML。 +2. AutoWSGR 的 `CombatPlan.from_yaml()`。 +3. AutoWSGR HTTP API 的 `CombatPlanRequest`。 + +GUI 当前允许在舰队槽位中使用 `nation` 和 `priority`,并在发送 API 前将其转换为 +`candidates`。旧后端对节点和部分枚举有校验,但对 `fleet` 没有正式约束。 + +## 目标 + +1. GUI 保存的 YAML 与后端直接读取的 YAML 使用同一份字段定义。 +2. 明确定义 YAML 到 HTTP API 运行时字段的转换,不允许“接口接受但执行时忽略”。 +3. 已经在 GUI 或后端实际使用的字段保持原语义,只定义缺失的字段和转换规则。 +4. 提供一份可供 Python 和 TypeScript 共用的 Schema。 +5. 不经过 GUI 的后端 YAML 路径也必须得到与 GUI 相同的解析结果。 + +## 建议的统一方向 + +统一契约需要明确区分两层字段: + +1. YAML 编排字段:保留 GUI 已使用的 `name`、`nation`、`ship_type`、`priority`、 + `min_level`、`max_level`。 +2. 后端运行时字段:`candidates`、`search_name`、`ship_type`、`min_level`、`max_level`。 + +GUI 已经使用 `nation` 筛选船池、使用 `priority` 排候选顺序,所以不能删除或改名。 +需要补充的是统一的“编排字段转运行时字段”规则。后端若要直接读取同一份 YAML,也必须执行 +等价转换;不能只在 GUI 中转换。 + +该方向尚未实现,最终字段需要在 GUI 和后端共同修改时确认。 + +## 当前改动概述 + +当前分支的 `autowsgr/combat/plan.py` 已增加: + +- `fleet_presets` 解析。 +- 只校验 `fleet_presets` 是否为列表,空列表表示不使用舰队预设。 +- 去除预设名称、舰名和槽位字符串字段的首尾空格。 +- `candidates` 去重。 +- 将每个 preset 整理为统一的 `name + ships` 结构。 + +后端原先新增的字段白名单、非空、六槽、等级范围和未知字段校验已删除。完整格式约束尚未 +同步 GUI 和 HTTP API,因此仍不是本 feat 的最终方案。 + +## 上游活动 YAML 改动影响(#516) + +### 结论 + +上游活动改动与 `fleet_presets` 没有直接冲突。两组改动都位于 +`CombatPlan.from_dict()`,但分别处理顶层地图字段和舰队字段,可以自动合并并同时保留。 + +该改动会影响统一 YAML Schema 对顶层字段的定义,因此后续不能再把 `chapter` 和 `map` +限制为整数,也不能继续使用独立的 `map_entrance` 字段。 + +### 上游确定的新语义 + +- 普通地图继续使用数字 `chapter` 和数字 `map`。 +- 活动地图使用 `chapter: E` 表示简单难度,使用 `chapter: H` 表示困难难度。 +- 活动入口写入 `map`:`1a` 表示第 1 图 α 入口,`1b` 表示第 1 图 β 入口。 +- `CombatPlan.from_dict()` 将 `map: 1a` 整理为 `map_id=1` 和内部字段 + `entrance='a'`;`entrance` 不是独立的 YAML 字段。 +- `map` 只接受数字、数字字符串或“数字 + a/b”;其他内容会抛出 `ValueError`。 +- `event` 保存活动目录名,例如 `"20260730"`,运行时进入 `CombatPlan.event_name`。 +- 原配置字段 `map_entrance` 已从后端配置模型删除,不应进入新的共享 Schema。 +- `NormalFightRunner` 根据 `chapter` 是否为 `E/H` 决定活动或普通战,并修正运行时 + `mode`;因此 `mode` 不再是这两类出击的唯一分流依据。 + +### 对 GUI 和 HTTP API 的影响 + +当前 AutoWSGR-GUI 尚不能无损读取这种活动 YAML: + +- `PlanData.chapter` 和 `PlanData.map` 仍定义为 `number`。 +- `PlanModel.fromYaml()` 对两个字段调用 `Number()`,会把 `H` 和 `1a` 都转换为 `0`。 +- `PlanData` 没有 `event` 字段,重新保存时会丢失活动名称。 + +HTTP API 虽然允许 `chapter` 和 `map` 为字符串,但 serializer 仍直接执行 +`map_id=request.map`,没有调用 `parse_map_value()`,也没有复制 `event_name`。因此直接通过 +API 传入 `chapter: H`、`map: 1a` 时,会得到错误的 `map_id='1a'`、空入口和空活动名称。 + +统一契约后续需要规定: + +1. `chapter` 为普通章节数字,或活动难度 `E/H`。 +2. `map` 为正整数,或匹配 `^\d+[aAbB]?$` 的字符串。 +3. 当 `chapter` 为 `E/H` 时允许入口后缀,并要求活动名称字段。 +4. YAML 的 `event`、API 的 `event_name` 和运行时 `CombatPlan.event_name` 必须有明确转换。 +5. GUI 与 API 必须复用后端 `parse_map_value()` 的等价规则,不能各自转换。 + +## TODO:AutoWSGR-GUI + +- GUI 加载 YAML 时,将 `nation`、`priority` 等编排字段转换为标准 `candidates`。 +- GUI 内部、保存 YAML 和发送 API 使用同一份标准舰队结构。 +- 将 `chapter`、`map` 扩展为活动格式,并保证 `H`、`E`、`1a`、`1b` 无损往返。 +- 增加并保留 YAML 顶层 `event` 字段,不在 GUI 保存时丢失。 +- 更新 GUI 的类型定义、舰队编辑器和内置 YAML。 +- 增加 GUI 加载、保存和 API 入参的契约测试。 +- GUI 改造完成后,再确定共享 Schema 和后端最终校验方式。 + +## 后端字段功能审计 + +### CombatPlan 顶层字段 + +| YAML 字段 | 状态 | 实际行为 | +| --- | --- | --- | +| `name` | 部分有效 | 仅用于日志和任务名称,不改变作战行为。 | +| `mode` | 部分有效 | 决定状态转移图;普通/活动 Runner 会再按 `chapter` 是否为 `E/H` 修正为 `normal/event`。 | +| `chapter` | 有效 | 普通战使用章节数字;活动使用 `E/H`,并据此选择普通或活动导航。 | +| `map` | 有效且已约束 | 接受数字、数字字符串或 `1a/1b` 格式;后缀表示活动入口,非法格式抛出 `ValueError`。 | +| `fleet_id` | 有效 | 用于选择出征舰队。 | +| `fleet` | 有效 | 旧格式固定舰名列表,准备页会执行换船;旧后端没有格式校验。 | +| `fleet_presets` | 当前分支部分有效 | 后端只解析和保存,不在作战入口轮询多个 preset;实际出击一次只执行一套舰队。 | +| `repair_mode` | 部分有效 | 会触发快速修理,但六槽配置被取最小值后作为全队修理策略,未逐槽执行。 | +| `fight_condition` | 有效 | 在战况选择页面点击对应选项。 | +| `selected_nodes` | 有效 | 作为节点白名单,不在列表中的节点会撤退或 SL。 | +| `node_defaults` | 有效 | 构造默认节点决策。 | +| `node_args` | 有效 | 覆盖指定节点的决策。 | +| `event` | 有效 | 活动目录名,解析后保存到 `CombatPlan.event_name`,用于加载活动地图节点数据。 | +| `map_entrance` | 已删除 | 入口已编码进 `map` 的 `a/b` 后缀,不应再写入 YAML 或共享 Schema。 | +| `entrance` | 非 YAML 字段 | 由 `map` 解析得到的内部字段,不应要求用户重复配置。 | +| `nodes` | 无效的 YAML 名称 | 架构文档示例写成了 `nodes`,实际解析器只读取 `node_args`。 | +| `endpoint_nodes` | GUI 有效 | 后端不读取;GUI 调度器用它判断本轮到达哪个节点后计为完成。 | + +`testing/plan` 中的 `times`、`gap`、`stop_condition`、`loot_count_ge` 属于测试或调度层元数据, +不由 `CombatPlan.from_yaml()` 解析。 + +### 舰队 preset 和槽位字段 + +| 字段 | 状态 | 实际行为 | +| --- | --- | --- | +| preset `name` | GUI 有效、后端仅保存 | GUI 用它展示和选择 preset;后端当前不使用它选择出击舰队。 | +| preset `ships` | 有效 | 作为该 preset 唯一的舰队槽位列表。 | +| slot `name` | 有效 | 作为该槽位的主选舰名。 | +| slot `candidates` | 有效 | 按顺序尝试候选,并参与槽位级唯一分配。 | +| slot `search_name` | 有效 | 用于搜索框关键字和自定义舰名区分。 | +| slot `min_level` | 部分有效 | 重新选船时 OCR 读取等级并过滤;当前槽已有同名舰时不会复核等级。 | +| slot `max_level` | 部分有效 | 与 `min_level` 相同,仅在重新选船时过滤。 | +| slot `ship_type` | 部分有效 | 重新选船时 OCR 识别舰种并过滤;当前槽已有同名舰时不会复核舰种。 | +| `priority` | GUI 有效 | GUI 用它调整候选顺序并转换为 `candidates`;后端当前不直接处理。 | +| `nation` | GUI 有效 | GUI 用它筛选船池并生成 `candidates`;后端当前没有国籍筛选能力。 | + +`ship_type` 当前运行时支持: + +`dd`、`cl`、`ca`、`cav`、`clt`、`bb`、`bc`、`bbv`、`cv`、`cvl`、`av`、`ss`、 +`ssg`、`cg`、`cgaa`、`ddg`、`ddgaa`、`bm`、`cbg`、`cf`,以及组合规则 +`ss_or_ssg`。 + +GUI 舰船数据还使用 `bbg` 表示导战,但当前后端 API 白名单和选船页舰种 OCR 表都不支持 +`bbg`。 + +### GUI 字段审计修正 + +判断字段是否有效必须同时检查 GUI 和后端: + +- `endpoint_nodes`:GUI 调度器用于判断一轮任务的完成节点。 +- preset `name`:GUI 用于显示和选择队伍预设。 +- `nation`:GUI 使用舰船数据库按国籍生成候选。 +- `priority`:GUI 用于排序候选。 +- `times`、`gap`、`stop_condition`、`loot_count_ge`:由 GUI 调度或任务层使用,不属于 + `CombatPlan` 战斗执行字段。 + +这些字段不能因为后端 `CombatPlan` 没有读取就删除。 + +### 密苏里舰种最小验证 + +GUI 舰船数据中存在: + +- `密苏里`:美国,`bb`,战列。 +- `密苏里·改`:美国,`bbg`,导战。 + +当前后端最小验证结果: + +```text +密苏里 / 战列 / bb: OCR='bb', match=True, API=accepted +密苏里·改 / 导战 / bbg: OCR=None, match=False, API=rejected +``` + +结论:战列型可以按 `ship_type=bb` 识别;导战型当前不能按 `ship_type=bbg` 识别。 + +### 突击者舰种实机验证 + +GUI 舰船数据中存在: + +- `突击者`:美国,`cvl`,轻母。 +- `突击者·改`:美国,`cv`,航母。 + +在 720P、240 DPI 的实机选船页中,对同一个槽位直接执行两次替换: + +```text +航母/cv: selected='突击者', detected_types=['cv'] +轻母/cvl: selected='突击者', detected_types=[None, 'cvl'] +``` + +测试通过。第二次选择中,第一次未识别出舰种时没有点击,识别出 `cvl` 后才选择,证明 +`ship_type` 在实际重新选船路径中生效。 + +该结果不代表现有编队验证完整:如果当前槽已经识别为同名 `突击者`,`change_fleet()` +仍可能只比较舰名并提前短路,不会重新验证 `cv/cvl`。 + +### 国籍筛选审计 + +当前后端战斗选船系统没有国籍筛选功能: + +- `FleetRuleRequest` 没有 `nation` 字段。 +- `ChooseShipPage` 没有国籍 OCR 或国籍筛选参数。 +- 后端舰名库没有可供选船逻辑使用的“舰名到国籍”映射。 + +国籍筛选目前完全在 GUI 的 `shipData.ts` 中完成,GUI 将筛选结果转换成 `candidates` +后再传给后端。 + +### NodeDecision 字段 + +以下字段在 YAML 路径中都有实际执行代码: + +- `formation` +- `night` +- `proceed` +- `proceed_stop` +- `enemy_rules` +- `enemy_formation_rules` +- `detour` +- `long_missile_support` +- `SL_when_spot_enemy_fails` +- `SL_when_detour_fails` +- `SL_when_enter_fight` +- `formation_when_spot_enemy_fails` + +注意:`NodeDecision` 内部属性叫 `formation_rules`,但 YAML 正式入参叫 +`enemy_formation_rules`。在 YAML 中写 `formation_rules` 会被 Pydantic 默认忽略。 + +### 枚举 + +| 枚举 | 有效值 | 状态 | +| --- | --- | --- | +| `mode` | `normal`、`battle`、`exercise`、`decisive`、`event` | 实际控制战斗状态机。 | +| `fight_condition` | `1` 至 `5` | 实际控制战况点击。 | +| `formation` | `1` 至 `5` | 实际控制阵型点击。 | +| `repair_mode` | `1` 中破修、`2` 大破修、`3` 不触发快速修理 | 有效,但列表未逐槽执行。 | +| `proceed_stop` | `1` 中破停止、`2` 大破停止、`3` 不因当前血量停止 | 支持逐槽判断;当前血量枚举最高为 `2`,所以阈值 `3` 实际不会触发停止。 | + +## HTTP API 审计结果 + +API 与 YAML 目前也不一致: + +- `NodeDecisionRequest.enemy_rules` 会被 API 接受,但 `build_combat_plan()` 没有复制到 + `NodeDecision`,因此通过直接 API plan 传入时不生效。 +- YAML 支持的 `enemy_formation_rules`、导弹支援和三个 SL 字段没有出现在 + `NodeDecisionRequest` 中。 +- `CombatPlanRequest.event_name` 会被 API 接受,但 `build_combat_plan()` 没有写入 + `CombatPlan.event_name`。 +- `CombatPlanRequest.map` 接受 `1a/1b` 字符串,但 `build_combat_plan()` 没有调用 + `parse_map_value()`;直接 API 路径不会拆出 `map_id` 和 `entrance`。 +- API 的 `fight_condition` 是整数,serializer 没有转换成 `FightCondition`,而执行器会访问 + `.value`,直接 API plan 路径存在类型错误风险。 +- `fleet_rules` 会由 task route 单独传给 Runner,因此实际生效,但没有进入 + `CombatPlan`,与 YAML 路径不是同一模型。 + +## 进度 + +- [x] 确认 GUI YAML 的舰队字段和转换位置。 +- [x] 确认 main 后端原有 YAML 约束范围。 +- [x] 审计 `combat-engine.md` 中的 CombatPlan 和 NodeDecision 字段。 +- [x] 追踪 `min_level`、`max_level`、`ship_type` 到实际选船逻辑。 +- [x] 找出文档、YAML、API 之间的缺失字段和命名差异。 +- [x] 复核后端未执行字段在 GUI 中的用途。 +- [x] 验证密苏里战列型和导战型的当前舰种识别能力。 +- [x] 确认国籍筛选当前只存在于 GUI。 +- [x] 将后端 `fleet_presets` 解析收缩为列表校验和三项基础整理。 +- [x] 审计上游活动 YAML 新格式及其对统一契约的影响。 +- [ ] 确认统一后的正式字段和 Schema 所有权。 +- [ ] 修改 AutoWSGR-GUI 的模型、编辑器、保存和 API 转换。 +- [ ] 修改 AutoWSGR 的 YAML parser、API schema 和 serializer。 +- [ ] 迁移两个项目中的内置 YAML。 +- [ ] 增加同一份样例在 GUI、YAML parser、API 三条路径上的契约测试。 +- [ ] 更新 `docs/architecture/combat-engine.md` 和使用文档。 + +## 验收标准 + +1. 同一份 YAML 通过 GUI 加载和后端直接读取时得到相同的运行时舰队规则。 +2. 不允许存在“校验通过但执行时未使用”的公开字段。 +3. 不允许未知字段被静默忽略。 +4. `min_level`、`max_level` 和 `ship_type` 对已有舰队与新选舰船采用一致的验证语义。 +5. 已在 GUI 或后端使用的字段保持原有语义。 +6. GUI 与后端使用同一份自动化 Schema 契约测试。 +7. 普通和活动 YAML 在 GUI、后端 YAML parser、HTTP API 三条路径中得到相同的 + `chapter`、`map_id`、`entrance` 和 `event_name`。 From b26ba19ee262bb25b848e0bdab4aae296e74c1b0 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:03:46 +0800 Subject: [PATCH 03/11] =?UTF-8?q?docs(feat):=20=E5=B0=86=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=AE=B0=E5=BD=95=E8=BF=81=E5=87=BA=E4=BB=93?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-decisive-fleet-change-algorithm-switch.md | 51 --- docs/features/feat-ocr-ship-name-matching.md | 105 ------- .../feat-refactor-decisive-fight-module.md | 43 --- docs/features/feat-release-summary.md | 149 --------- docs/features/feat-smart-fleet-change.md | 156 --------- .../features/feat-unified-combat-plan-yaml.md | 296 ------------------ 6 files changed, 800 deletions(-) delete mode 100644 docs/features/feat-decisive-fleet-change-algorithm-switch.md delete mode 100644 docs/features/feat-ocr-ship-name-matching.md delete mode 100644 docs/features/feat-refactor-decisive-fight-module.md delete mode 100644 docs/features/feat-release-summary.md delete mode 100644 docs/features/feat-smart-fleet-change.md delete mode 100644 docs/features/feat-unified-combat-plan-yaml.md diff --git a/docs/features/feat-decisive-fleet-change-algorithm-switch.md b/docs/features/feat-decisive-fleet-change-algorithm-switch.md deleted file mode 100644 index 79a08317..00000000 --- a/docs/features/feat-decisive-fleet-change-algorithm-switch.md +++ /dev/null @@ -1,51 +0,0 @@ -# 决战换船算法开关 - -## 状态 - -代码已完成,待决战实机验证。 - -## 目标 - -新的换船算法先在常规出征中使用,决战是否启用由独立开关控制。 -关闭开关不会停止决战,而是继续使用原有的决战换船和 OCR 流程。 - -## 配置 - -```yaml -decisive_battle: - use_new_fleet_change_algorithm: false -``` - -- `false`:默认值,使用原有决战换船流程。 -- `true`:决战使用新的换船算法。 - -API 请求支持同名字段。 - -## 原有决战 OCR 流程 - -1. 准备页调用 `detect_fleet()`,从六个槽位的舰名区域识别当前舰队。 -2. 不传 `expected_names`,不使用新算法提供的目标舰名上下文。 -3. 决战选船页没有搜索框,通过 DLL 定位舰船行,再使用 OCR 匹配并点击。 -4. 完成成员替换和顺序调整后,再次 OCR 验证结果。 - -`ship_name_match_confidence` 是独立的 OCR feat。该配置启用时, -原有决战流程仍会使用共享 OCR 模块中的置信度匹配。 - -## 代码改动 - -- `DecisiveConfig` 和决战 API 请求增加 `use_new_fleet_change_algorithm`。 -- `DecisiveBattlePreparationPage.change_fleet()` 根据开关选择算法。 -- `legacy_fleet_change.py` 保留原有决战换船流程。 -- 新换船算法本身不处理开关,避免常规出征受到影响。 - -## 验证 - -- 单元测试确认默认使用原有流程。 -- 单元测试确认开启后使用新算法。 -- 单元测试确认原有流程调用 OCR 时不传目标舰名上下文。 - -## TODO - -- 在决战环境中分别实测开关关闭和开启。 -- 实机确认旧流程的舰队识别、直接列表选船和顺序调整。 -- 决战入口独立重构见 `feat-refactor-decisive-fight-module.md`。 diff --git a/docs/features/feat-ocr-ship-name-matching.md b/docs/features/feat-ocr-ship-name-matching.md deleted file mode 100644 index a6630cd2..00000000 --- a/docs/features/feat-ocr-ship-name-matching.md +++ /dev/null @@ -1,105 +0,0 @@ -# Feat:舰名 OCR 匹配调优 - -## 状态 - -- 日期:2026-08-01 -- 状态:已完成首版,开放 dev 测试 -- 范围:舰名 OCR、船池匹配和目标舰队上下文,不包含 YAML 格式。 - -## 为什么要改 - -原实现只使用 Levenshtein 编辑距离在完整船池中找最近舰名,存在以下问题: - -- `·`、`:`、`-` 和空格等符号识别不稳定,同一舰名可能因为标点不同而匹配失败。 -- 自定义舰名常表现为“基础舰名 + 后缀”,纯编辑距离容易拒绝正确基础舰名。 -- 长舰名可能被 OCR 截断,但短前缀直接匹配又会误伤大量一至三字舰名。 -- `Z1`、`Z16`、`Z17` 等短舰名相近,全船池匹配容易选错。 -- 智能换船已经知道目标队伍,但原检测无法利用这个上下文修正最后一个模糊结果。 - -## 匹配流程 - -```mermaid -flowchart TD - A[OCR 原始文字] --> B[应用通用文字补丁] - B --> C[去标点并统一大小写] - C --> D{船池关系} - D -->|完全一致| E[唯一候选直接命中] - D -->|基础舰名加后缀| F[最长基础舰名加置信度] - D -->|长舰名被截断| G[唯一长前缀加置信度] - D -->|没有前缀关系| H[原编辑距离匹配] - E --> I[槽位舰名] - F --> I - G --> I - H --> I - I --> J{已识别大部分目标且结果失败或重复} - J -->|是| K[从剩余目标中唯一补全] - J -->|否| L[保留船池结果] - - style E fill:#c8e6c9,color:#1a5e20 - style F fill:#bbdefb,color:#0d47a1 - style G fill:#bbdefb,color:#0d47a1 - style K fill:#fff3e0,color:#e65100 -``` - -## 具体改动 - -### 可配置置信度 - -- `OCRConfig` 新增 `ship_name_match_confidence`,默认值为 `0.65`,取值范围为 `0` 至 `1`。 -- `0` 表示关闭船池感知规则,继续使用原编辑距离逻辑。 -- `launcher.py` 创建 OCR 引擎时同步参数。 -- 启用后输出日志:`OCR置信度匹配机制加载(当前参数:0.65)`。 - -### 船池感知匹配 - -- 匹配前移除标点和空格,统一字母大小写,保留中文、字母和数字。 -- 归一化后完全一致时,只有唯一候选才接受。 -- OCR 文字以基础舰名开头时,按自定义后缀处理,基础舰名至少保留两个字符,并优先最长基础舰名。 -- 舰名以 OCR 文字开头时,按截断处理;OCR 至少保留四个字符,且共享前缀的候选只能有一个。 -- 置信度使用对称前缀 Dice:`2 × 公共前缀长度 ÷ (OCR 长度 + 舰名长度)`。 -- 同时符合自定义后缀和截断关系、候选不唯一或低于阈值时拒绝猜测。 -- 不存在可解释的前缀关系时,保留原编辑距离匹配,兼容旧行为。 -- 选船页比较目标舰名与 OCR 船池结果时复用同一套匹配规则。 - -### 目标舰队上下文 - -- `detect_fleet()` 支持传入 `expected_names`。 -- 完整船池必须先识别出目标队伍中除至多一艘外的其他成员,才启用上下文。 -- 只有船池匹配失败,或重复命中已被其他槽位占用的目标舰名时才补全。 -- 只从尚未占用的目标舰名中选择编辑距离 `2` 内唯一的最近项。 -- 智能换船使用该能力;普通检测、`event_fight.py`、`normal_fight.py` 和旧决战流程不传目标上下文。 - -### 实现精简 - -完全一致、自定义后缀和 OCR 截断原先拆成四个一次性辅助函数,现合并到一个船池匹配函数。 -行为保持不变,`ocr.py` 相对主分支的改动由约 `+219` 行减少到约 `+122` 行。 - -## 已解决 - -- 标点差异造成的同舰名匹配失败。 -- 可解释的自定义后缀和唯一长舰名截断。 -- 短舰名、共享前缀和归一化后重名时的盲目猜测。 -- `Z1` 系列在目标舰队大部分成员已确认时的最后一项补全。 -- 置信度参数从配置加载并在启动日志中可见。 - -## 未解决 - -- 目标上下文是带目标答案的补全,不是独立于目标的二次验证。 -- 上下文补全使用固定编辑距离 `2`,暂不读取 `ship_name_match_confidence`。 -- 非前缀型 OCR 错字仍依赖旧编辑距离,无法使用前缀置信度解释。 -- 自定义舰名、基础舰名和同舰别名尚未形成统一身份模型。 -- 本地补充的 `autowsgr/data/shipnames.yaml` 不进入本次提交。 - -## 验证 - -- OCR 基础与船池匹配测试:`71 passed`。 -- 881 艘船池的自定义后缀、阈值边界和截断场景:`8 passed`。 -- 准备页目标上下文和换船测试:`83 passed`。 -- Ruff、格式检查和 `git diff --check` 通过。 - -## TODO - -- 评估目标上下文是否与 `ship_name_match_confidence` 共用阈值。 -- 为目标上下文补充独立实机日志,确认误补全比例。 -- 统一基础舰名、自定义舰名和别名的身份判断。 -- 扩充非前缀错字、短舰名和归一化重名样本。 diff --git a/docs/features/feat-refactor-decisive-fight-module.md b/docs/features/feat-refactor-decisive-fight-module.md deleted file mode 100644 index 92e7f34b..00000000 --- a/docs/features/feat-refactor-decisive-fight-module.md +++ /dev/null @@ -1,43 +0,0 @@ -# 决战模块入口重构 - -## 状态 - -TODO,暂不实施。 - -## 目标 - -将决战的作战入口从现有内部目录中整理出来,使其与 -`autowsgr/ops/normal_fight.py`、`autowsgr/ops/event_fight.py` -处于同一层级。 - -建议目标入口: - -```text -autowsgr/ops/decisive_fight.py -``` - -该入口只负责组织完整决战流程,具体的地图状态、舰队计算和节点处理 -仍可保留在独立的决战内部模块中。 - -## 原因 - -- 三种作战模式应有统一、清晰的外部调用入口。 -- 调度器和 API 不需要了解决战内部目录结构。 -- 决战换船、导航和战斗状态逻辑可以分别测试。 -- 后续替换决战换船算法时,不影响普通战和活动战入口。 - -## TODO - -- 盘点调度器、API 和测试中所有决战入口调用。 -- 定义与 `run_normal_fight()` 同层级的决战启动函数。 -- 将参数转换与流程编排集中到 `decisive_fight.py`。 -- 保持现有决战配置和 API 入参兼容。 -- 保留内部状态机、地图控制和舰队计算模块。 -- 增加旧入口兼容测试和完整决战回归。 -- 完成迁移后再删除旧入口,避免一次性改动过大。 - -## 本轮不做 - -- 不移动现有决战生产代码。 -- 不修改决战状态机。 -- 不把换船算法开关与本次架构重构混在一起。 diff --git a/docs/features/feat-release-summary.md b/docs/features/feat-release-summary.md deleted file mode 100644 index 47539125..00000000 --- a/docs/features/feat-release-summary.md +++ /dev/null @@ -1,149 +0,0 @@ -# 本轮智能换船与 OCR 功能总览 - -## 发布范围 - -本轮共保留 5 个专题 feat: - -| 数量 | 状态 | 专题 | -| --- | --- | --- | -| 3 | 已实现,进入 dev 测试 | OCR 舰名匹配、智能换船、决战算法开关 | -| 1 | 部分实现 | GUI 与后端作战计划 YAML 契约 | -| 1 | 仅记录 TODO | 决战模块独立入口重构 | - -原有专题文档全部保留,本文件只提供发布总览,不代替各专题的实现说明。 - -## 整体流程 - -```mermaid -flowchart LR - A[YAML 或 API 舰队规则] --> B[解析六个槽位] - B --> C[智能换船] - C --> D[选船页按舰名 舰种 等级筛选] - D --> E[OCR 识别最终舰队] - E --> F{验证通过} - F -->|是| G[进入战斗] - F -->|否| H[局部修正或停止] - I[决战算法开关] --> C - I --> J[旧决战换船流程] - - style C fill:#bbdefb,color:#0d47a1 - style E fill:#f3e5f5,color:#7b1fa2 - style G fill:#c8e6c9,color:#1a5e20 - style H fill:#ffcdd2,color:#8b1a1a - style I fill:#fff3e0,color:#e65100 -``` - -## 1. 舰名 OCR 匹配调优 - -文档:`feat-ocr-ship-name-matching.md` - -功能: - -- 忽略标点和大小写差异,保留中文、字母和数字。 -- 支持唯一的基础舰名、自定义后缀和长舰名截断关系。 -- 使用可配置置信度拒绝短舰名、歧义前缀和低可信匹配。 -- 智能换船可在大部分目标已确认后,用目标上下文补全最后一个模糊结果。 - -解决: - -- 标点、自定义后缀和 OCR 截断导致的舰名匹配失败。 -- `Z1` 系列等相近短舰名在目标舰队中的部分识别问题。 - -未解决: - -- 目标上下文不是独立二次验证。 -- 上下文仍使用固定编辑距离 `2`。 -- 基础舰名、自定义舰名和别名尚未统一身份。 - -## 2. 智能换船算法 - -文档:`feat-smart-fleet-change.md` - -功能: - -- 六个槽位分别使用自己的固定舰名或候选列表。 -- 使用回溯为槽位分配不同舰名,避免同舰名重复入队。 -- 首次完整对齐,失败后只修正错误槽位。 -- 1 队槽位 0 先替换后移除,避免舰队被清空。 -- 修复 `Lv.` 标签与等级数字分离、`110` 被识别为 `Il0` 等等级 OCR 问题。 -- 换船失败返回 `False`,作战入口立即停止。 - -解决: - -- 槽位候选被错误合并成全局候选。 -- `AB -> C` 时先移除导致一队为空。 -- `min_level=100` 时无法识别实际为 `110` 的舰船。 -- 验证失败后重复调整整支舰队。 -- 换船失败后仍带错误舰队出征。 - -未解决: - -- 已在队伍中的同名舰只比较舰名,尚未复核 `ship_type`、`min_level` 和 `max_level`。 -- 1 队先替换后移除尚未完成实机验证。 - -## 3. 决战换船算法开关 - -文档:`feat-decisive-fleet-change-algorithm-switch.md` - -功能: - -- 默认继续使用原决战换船流程。 -- 开启 `use_new_fleet_change_algorithm` 后使用智能换船。 -- YAML 配置和决战 API 使用同一个开关字段。 - -解决: - -- 新算法无法逐步开放给决战测试的问题。 -- 关闭新算法时决战无法继续使用旧流程的问题。 - -未解决: - -- 开关开启和关闭都需要完成决战整章实机回归。 - -## 4. 作战计划 YAML 契约 - -文档:`feat-unified-combat-plan-yaml.md` - -当前完成: - -- 后端读取并保存 `fleet_presets`。 -- 只做列表类型检查、字符串去空格和候选顺序去重。 -- 不在后端轮询多套 preset;一次出击只执行 GUI 或 API 选定的一套舰队。 - -未解决: - -- GUI、YAML parser 和 HTTP API 尚未共享同一份 Schema。 -- `priority`、`nation` 等 GUI 编排字段尚未统一转换。 -- 部分 API 字段仍存在“接受但未传入运行模型”的情况。 - -## 5. 决战模块入口重构 - -文档:`feat-refactor-decisive-fight-module.md` - -本轮只记录设计,不移动生产代码。目标是在后续将决战入口整理为 -`autowsgr/ops/decisive_fight.py`,与普通战和活动战入口平级。 - -## 发布前本地验证 - -- 准备页与智能换船单元测试:`83 passed`。 -- OCR 基础匹配测试:`71 passed`。 -- 881 艘船池场景测试:`8 passed`。 -- Ruff、格式检查和 `git diff --check` 通过。 - -测试代码仅用于本地验证,不进入本次个人分支提交。 - -## 后续 TODO - -1. 完成决战旧流程与新算法的整章实机回归。 -2. 实机验证 1 队槽位 0 的先替换后移除流程。 -3. 复核已有同名舰的舰种和等级条件。 -4. 统一目标上下文与全船池置信度规则。 -5. 完成 GUI、YAML 和 API 的共享 Schema。 -6. 将决战入口重构到 `autowsgr/ops/decisive_fight.py`。 - -## 本次提交边界 - -- 提交生产代码和 6 份 feat 文档。 -- 不提交 `testing/`、`.dbg/`、调试文档和本地测试计划。 -- 不提交本地临时修改的 `autowsgr/data/shipnames.yaml`。 -- 不提交本地 `usersettings.yaml`。 diff --git a/docs/features/feat-smart-fleet-change.md b/docs/features/feat-smart-fleet-change.md deleted file mode 100644 index f644a0f9..00000000 --- a/docs/features/feat-smart-fleet-change.md +++ /dev/null @@ -1,156 +0,0 @@ -# 智能换船算法 - -## 状态 - -核心算法已实现,已完成非核心代码清理和重复逻辑精简。 - -## 目标 - -根据六个舰队槽位的规则完成换船,并保证: - -- 固定舰名和槽位候选都能使用。 -- 每个槽位只使用自己的候选。 -- 同一舰队不会选择两个标准舰名相同的舰船。 -- 1 队槽位 0 不会被清空。 -- 首次整体调整失败后,只修正错误槽位。 -- 换船结果经过 OCR 再次确认,失败时返回 `False`。 - -## 输入 - -每个槽位支持三种形式: - -```yaml -ships: - - 飞龙 - - candidates: [岛风, 黑潮] - search_name: 岛风 - ship_type: dd - min_level: 100 - max_level: 110 - - null -``` - -- 字符串:固定舰名。 -- 规则对象:按顺序尝试 `candidates`,并把舰种、等级等条件交给选船页面。 -- `null`:该槽位应为空。 - -不足六个槽位时在末尾补空位。 -超过六个槽位时只读取前六个槽位。 - -每次只接收并执行一套 `ships`。不会在第一套舰队失败后继续尝试其他 -preset,换船失败时直接停止当前作战流程。 - -## 当前流程 - -```mermaid -flowchart TD - A[读取六个槽位规则] --> B[整理舰名和候选] - B --> C{能否为各槽分配不同舰名} - C -->|否| D[报错停止] - C -->|是| E[OCR 识别当前舰队] - E --> F{当前舰队已满足目标} - F -->|是| G[返回成功] - F -->|否| H{第几次执行} - H -->|首次| I[完整成员对齐] - H -->|重试| J[只修正错误槽位] - I --> K[拖拽调整顺序] - J --> K - K --> L[OCR 验证最终舰队] - L -->|成功| G - L -->|失败且未超过两次重试| E - L -->|仍失败| M[返回失败] - - style G fill:#c8e6c9,color:#1a5e20 - style D fill:#ffcdd2,color:#8b1a1a - style M fill:#ffcdd2,color:#8b1a1a - style I fill:#bbdefb,color:#0d47a1 - style J fill:#fff3e0,color:#e65100 -``` - -## 核心改动 - -### 槽位候选 - -候选不再合并成全局船池。每个槽位只读取自己的 `candidates`, -并按填写顺序选择。 - -### 同名舰分配 - -换船前使用回溯分配六个槽位,确保不同槽位不会得到同一个标准舰名。 -如果候选无法组成不重复的舰队,直接报错,不进入选船页面。 - -### 完整对齐 - -第一次执行时: - -1. 保留当前舰队中已经满足目标的舰船。 -2. 先替换或补充缺少的舰船。 -3. 再从后往前移除多余舰船。 -4. 因槽位压缩导致缺员时再次补齐。 - -### 1 队槽位 0 - -1 队槽位 0 不能为空。执行 `AB -> C` 时先把槽位 0 的 `A` -直接替换成 `C`,然后移除 `B`,不会先把舰队清空。 - -### 局部修正 - -首次验证失败后不再整体重做。算法先找出错误槽位,只替换或移除这些槽位, -随后重新排序和验证,最多局部修正两次。 - -### 实际入队舰名 - -候选第一项不代表实际入队舰船。选船页面会返回实际选择结果, -算法用该结果更新目标和后续验证,不使用 `candidates[0]` 伪造舰名。 - -选船页面已经按舰名、`ship_type`、`min_level`、`max_level` 完成筛选时, -换船算法信任选船结果。最终 OCR 只确认舰名和槽位,不再二次识别舰种和等级。 - -### 等级 OCR - -- 等级 `110` 在 720P 下可能被识别为 `Il0`、`ll0`,允许两个数字易混淆字符。 -- `Lv.` 标签和等级数字被 OCR 拆成两个结果时,合并同一区域内的高置信度纯数字。 -- 没有识别到 `Lv.` 标签时,只接受大于等于 `100` 的三位等级,避免把其他数字当成等级。 - -该修复解决了选船页已经显示 `110`,但 `min_level=100` 仍被判断为无可用舰船的问题。 - -## 与其他 feat 的边界 - -- OCR 原文、模糊匹配和目标舰名上下文属于 - `feat-ocr-ship-name-matching.md`。 -- GUI 与后端 YAML 格式统一属于 - `feat-unified-combat-plan-yaml.md`。 -- 决战是否启用新算法属于 - `feat-decisive-fleet-change-algorithm-switch.md`。 -- 作战执行器只调用 `change_fleet()` 并处理成功或失败,不参与换船细节。 - -## 已删除的非核心代码 - -- `_report_fleet_debug()`、`_report_level_debug()` 及所有本地网络调试上报。 -- `change_fleet_with_fallback()` 多 preset 轮询。 -- 仅供测试读取的 `last_resolved_ship_names` 状态及对应测试。 -- 只测试多 preset fallback 的专项实机测试。 - -`expected_names` 已确定保留,具体启用条件和已知边界记录在 -`feat-ocr-ship-name-matching.md`。 - -## TODO - -- 已在队伍中的同名舰船只根据舰名判断,尚未重新确认 - `ship_type`、`min_level`、`max_level`。 -- 后续只处理上述“已有同名舰是否应触发重新选择”的问题。真正进入选船页面 - 并按约束选中后,不增加二次确认。 - -## 已完成验证 - -- 1 队 `AB -> C` 的替换顺序单元测试。 -- 槽位级候选和同名舰去重单元测试。 -- 固定舰名、候选和局部修正单元测试。 -- 突击者同名舰的 `cv`、`cvl` 舰种实机选择。 -- 决战旧流程和新算法的首次换船实机验证。 - -## 未完成验证 - -- 决战旧流程完整章节回归中途停止,尚未验证全链路结束。 -- 1 队 `AB -> C` 尚未完成实机验证。 -- 已在队伍中的同名舰是否符合舰种和等级条件。 diff --git a/docs/features/feat-unified-combat-plan-yaml.md b/docs/features/feat-unified-combat-plan-yaml.md deleted file mode 100644 index 0fdd1b97..00000000 --- a/docs/features/feat-unified-combat-plan-yaml.md +++ /dev/null @@ -1,296 +0,0 @@ -# Feat: 统一 GUI 与后端作战计划 YAML 契约 - -## 状态 - -- 日期:2026-08-01 -- 阶段:后端解析已收口,GUI 改造待办 -- 实现状态:暂停,择日继续 GUI 部分 -- 当前结论:`combat/plan.py` 只做列表类型校验和基础整理,严格格式约束留给 GUI - 与后续共享 Schema。 - -## 背景 - -当前存在三套并不完全一致的作战计划入参: - -1. AutoWSGR-GUI 保存和编辑的 YAML。 -2. AutoWSGR 的 `CombatPlan.from_yaml()`。 -3. AutoWSGR HTTP API 的 `CombatPlanRequest`。 - -GUI 当前允许在舰队槽位中使用 `nation` 和 `priority`,并在发送 API 前将其转换为 -`candidates`。旧后端对节点和部分枚举有校验,但对 `fleet` 没有正式约束。 - -## 目标 - -1. GUI 保存的 YAML 与后端直接读取的 YAML 使用同一份字段定义。 -2. 明确定义 YAML 到 HTTP API 运行时字段的转换,不允许“接口接受但执行时忽略”。 -3. 已经在 GUI 或后端实际使用的字段保持原语义,只定义缺失的字段和转换规则。 -4. 提供一份可供 Python 和 TypeScript 共用的 Schema。 -5. 不经过 GUI 的后端 YAML 路径也必须得到与 GUI 相同的解析结果。 - -## 建议的统一方向 - -统一契约需要明确区分两层字段: - -1. YAML 编排字段:保留 GUI 已使用的 `name`、`nation`、`ship_type`、`priority`、 - `min_level`、`max_level`。 -2. 后端运行时字段:`candidates`、`search_name`、`ship_type`、`min_level`、`max_level`。 - -GUI 已经使用 `nation` 筛选船池、使用 `priority` 排候选顺序,所以不能删除或改名。 -需要补充的是统一的“编排字段转运行时字段”规则。后端若要直接读取同一份 YAML,也必须执行 -等价转换;不能只在 GUI 中转换。 - -该方向尚未实现,最终字段需要在 GUI 和后端共同修改时确认。 - -## 当前改动概述 - -当前分支的 `autowsgr/combat/plan.py` 已增加: - -- `fleet_presets` 解析。 -- 只校验 `fleet_presets` 是否为列表,空列表表示不使用舰队预设。 -- 去除预设名称、舰名和槽位字符串字段的首尾空格。 -- `candidates` 去重。 -- 将每个 preset 整理为统一的 `name + ships` 结构。 - -后端原先新增的字段白名单、非空、六槽、等级范围和未知字段校验已删除。完整格式约束尚未 -同步 GUI 和 HTTP API,因此仍不是本 feat 的最终方案。 - -## 上游活动 YAML 改动影响(#516) - -### 结论 - -上游活动改动与 `fleet_presets` 没有直接冲突。两组改动都位于 -`CombatPlan.from_dict()`,但分别处理顶层地图字段和舰队字段,可以自动合并并同时保留。 - -该改动会影响统一 YAML Schema 对顶层字段的定义,因此后续不能再把 `chapter` 和 `map` -限制为整数,也不能继续使用独立的 `map_entrance` 字段。 - -### 上游确定的新语义 - -- 普通地图继续使用数字 `chapter` 和数字 `map`。 -- 活动地图使用 `chapter: E` 表示简单难度,使用 `chapter: H` 表示困难难度。 -- 活动入口写入 `map`:`1a` 表示第 1 图 α 入口,`1b` 表示第 1 图 β 入口。 -- `CombatPlan.from_dict()` 将 `map: 1a` 整理为 `map_id=1` 和内部字段 - `entrance='a'`;`entrance` 不是独立的 YAML 字段。 -- `map` 只接受数字、数字字符串或“数字 + a/b”;其他内容会抛出 `ValueError`。 -- `event` 保存活动目录名,例如 `"20260730"`,运行时进入 `CombatPlan.event_name`。 -- 原配置字段 `map_entrance` 已从后端配置模型删除,不应进入新的共享 Schema。 -- `NormalFightRunner` 根据 `chapter` 是否为 `E/H` 决定活动或普通战,并修正运行时 - `mode`;因此 `mode` 不再是这两类出击的唯一分流依据。 - -### 对 GUI 和 HTTP API 的影响 - -当前 AutoWSGR-GUI 尚不能无损读取这种活动 YAML: - -- `PlanData.chapter` 和 `PlanData.map` 仍定义为 `number`。 -- `PlanModel.fromYaml()` 对两个字段调用 `Number()`,会把 `H` 和 `1a` 都转换为 `0`。 -- `PlanData` 没有 `event` 字段,重新保存时会丢失活动名称。 - -HTTP API 虽然允许 `chapter` 和 `map` 为字符串,但 serializer 仍直接执行 -`map_id=request.map`,没有调用 `parse_map_value()`,也没有复制 `event_name`。因此直接通过 -API 传入 `chapter: H`、`map: 1a` 时,会得到错误的 `map_id='1a'`、空入口和空活动名称。 - -统一契约后续需要规定: - -1. `chapter` 为普通章节数字,或活动难度 `E/H`。 -2. `map` 为正整数,或匹配 `^\d+[aAbB]?$` 的字符串。 -3. 当 `chapter` 为 `E/H` 时允许入口后缀,并要求活动名称字段。 -4. YAML 的 `event`、API 的 `event_name` 和运行时 `CombatPlan.event_name` 必须有明确转换。 -5. GUI 与 API 必须复用后端 `parse_map_value()` 的等价规则,不能各自转换。 - -## TODO:AutoWSGR-GUI - -- GUI 加载 YAML 时,将 `nation`、`priority` 等编排字段转换为标准 `candidates`。 -- GUI 内部、保存 YAML 和发送 API 使用同一份标准舰队结构。 -- 将 `chapter`、`map` 扩展为活动格式,并保证 `H`、`E`、`1a`、`1b` 无损往返。 -- 增加并保留 YAML 顶层 `event` 字段,不在 GUI 保存时丢失。 -- 更新 GUI 的类型定义、舰队编辑器和内置 YAML。 -- 增加 GUI 加载、保存和 API 入参的契约测试。 -- GUI 改造完成后,再确定共享 Schema 和后端最终校验方式。 - -## 后端字段功能审计 - -### CombatPlan 顶层字段 - -| YAML 字段 | 状态 | 实际行为 | -| --- | --- | --- | -| `name` | 部分有效 | 仅用于日志和任务名称,不改变作战行为。 | -| `mode` | 部分有效 | 决定状态转移图;普通/活动 Runner 会再按 `chapter` 是否为 `E/H` 修正为 `normal/event`。 | -| `chapter` | 有效 | 普通战使用章节数字;活动使用 `E/H`,并据此选择普通或活动导航。 | -| `map` | 有效且已约束 | 接受数字、数字字符串或 `1a/1b` 格式;后缀表示活动入口,非法格式抛出 `ValueError`。 | -| `fleet_id` | 有效 | 用于选择出征舰队。 | -| `fleet` | 有效 | 旧格式固定舰名列表,准备页会执行换船;旧后端没有格式校验。 | -| `fleet_presets` | 当前分支部分有效 | 后端只解析和保存,不在作战入口轮询多个 preset;实际出击一次只执行一套舰队。 | -| `repair_mode` | 部分有效 | 会触发快速修理,但六槽配置被取最小值后作为全队修理策略,未逐槽执行。 | -| `fight_condition` | 有效 | 在战况选择页面点击对应选项。 | -| `selected_nodes` | 有效 | 作为节点白名单,不在列表中的节点会撤退或 SL。 | -| `node_defaults` | 有效 | 构造默认节点决策。 | -| `node_args` | 有效 | 覆盖指定节点的决策。 | -| `event` | 有效 | 活动目录名,解析后保存到 `CombatPlan.event_name`,用于加载活动地图节点数据。 | -| `map_entrance` | 已删除 | 入口已编码进 `map` 的 `a/b` 后缀,不应再写入 YAML 或共享 Schema。 | -| `entrance` | 非 YAML 字段 | 由 `map` 解析得到的内部字段,不应要求用户重复配置。 | -| `nodes` | 无效的 YAML 名称 | 架构文档示例写成了 `nodes`,实际解析器只读取 `node_args`。 | -| `endpoint_nodes` | GUI 有效 | 后端不读取;GUI 调度器用它判断本轮到达哪个节点后计为完成。 | - -`testing/plan` 中的 `times`、`gap`、`stop_condition`、`loot_count_ge` 属于测试或调度层元数据, -不由 `CombatPlan.from_yaml()` 解析。 - -### 舰队 preset 和槽位字段 - -| 字段 | 状态 | 实际行为 | -| --- | --- | --- | -| preset `name` | GUI 有效、后端仅保存 | GUI 用它展示和选择 preset;后端当前不使用它选择出击舰队。 | -| preset `ships` | 有效 | 作为该 preset 唯一的舰队槽位列表。 | -| slot `name` | 有效 | 作为该槽位的主选舰名。 | -| slot `candidates` | 有效 | 按顺序尝试候选,并参与槽位级唯一分配。 | -| slot `search_name` | 有效 | 用于搜索框关键字和自定义舰名区分。 | -| slot `min_level` | 部分有效 | 重新选船时 OCR 读取等级并过滤;当前槽已有同名舰时不会复核等级。 | -| slot `max_level` | 部分有效 | 与 `min_level` 相同,仅在重新选船时过滤。 | -| slot `ship_type` | 部分有效 | 重新选船时 OCR 识别舰种并过滤;当前槽已有同名舰时不会复核舰种。 | -| `priority` | GUI 有效 | GUI 用它调整候选顺序并转换为 `candidates`;后端当前不直接处理。 | -| `nation` | GUI 有效 | GUI 用它筛选船池并生成 `candidates`;后端当前没有国籍筛选能力。 | - -`ship_type` 当前运行时支持: - -`dd`、`cl`、`ca`、`cav`、`clt`、`bb`、`bc`、`bbv`、`cv`、`cvl`、`av`、`ss`、 -`ssg`、`cg`、`cgaa`、`ddg`、`ddgaa`、`bm`、`cbg`、`cf`,以及组合规则 -`ss_or_ssg`。 - -GUI 舰船数据还使用 `bbg` 表示导战,但当前后端 API 白名单和选船页舰种 OCR 表都不支持 -`bbg`。 - -### GUI 字段审计修正 - -判断字段是否有效必须同时检查 GUI 和后端: - -- `endpoint_nodes`:GUI 调度器用于判断一轮任务的完成节点。 -- preset `name`:GUI 用于显示和选择队伍预设。 -- `nation`:GUI 使用舰船数据库按国籍生成候选。 -- `priority`:GUI 用于排序候选。 -- `times`、`gap`、`stop_condition`、`loot_count_ge`:由 GUI 调度或任务层使用,不属于 - `CombatPlan` 战斗执行字段。 - -这些字段不能因为后端 `CombatPlan` 没有读取就删除。 - -### 密苏里舰种最小验证 - -GUI 舰船数据中存在: - -- `密苏里`:美国,`bb`,战列。 -- `密苏里·改`:美国,`bbg`,导战。 - -当前后端最小验证结果: - -```text -密苏里 / 战列 / bb: OCR='bb', match=True, API=accepted -密苏里·改 / 导战 / bbg: OCR=None, match=False, API=rejected -``` - -结论:战列型可以按 `ship_type=bb` 识别;导战型当前不能按 `ship_type=bbg` 识别。 - -### 突击者舰种实机验证 - -GUI 舰船数据中存在: - -- `突击者`:美国,`cvl`,轻母。 -- `突击者·改`:美国,`cv`,航母。 - -在 720P、240 DPI 的实机选船页中,对同一个槽位直接执行两次替换: - -```text -航母/cv: selected='突击者', detected_types=['cv'] -轻母/cvl: selected='突击者', detected_types=[None, 'cvl'] -``` - -测试通过。第二次选择中,第一次未识别出舰种时没有点击,识别出 `cvl` 后才选择,证明 -`ship_type` 在实际重新选船路径中生效。 - -该结果不代表现有编队验证完整:如果当前槽已经识别为同名 `突击者`,`change_fleet()` -仍可能只比较舰名并提前短路,不会重新验证 `cv/cvl`。 - -### 国籍筛选审计 - -当前后端战斗选船系统没有国籍筛选功能: - -- `FleetRuleRequest` 没有 `nation` 字段。 -- `ChooseShipPage` 没有国籍 OCR 或国籍筛选参数。 -- 后端舰名库没有可供选船逻辑使用的“舰名到国籍”映射。 - -国籍筛选目前完全在 GUI 的 `shipData.ts` 中完成,GUI 将筛选结果转换成 `candidates` -后再传给后端。 - -### NodeDecision 字段 - -以下字段在 YAML 路径中都有实际执行代码: - -- `formation` -- `night` -- `proceed` -- `proceed_stop` -- `enemy_rules` -- `enemy_formation_rules` -- `detour` -- `long_missile_support` -- `SL_when_spot_enemy_fails` -- `SL_when_detour_fails` -- `SL_when_enter_fight` -- `formation_when_spot_enemy_fails` - -注意:`NodeDecision` 内部属性叫 `formation_rules`,但 YAML 正式入参叫 -`enemy_formation_rules`。在 YAML 中写 `formation_rules` 会被 Pydantic 默认忽略。 - -### 枚举 - -| 枚举 | 有效值 | 状态 | -| --- | --- | --- | -| `mode` | `normal`、`battle`、`exercise`、`decisive`、`event` | 实际控制战斗状态机。 | -| `fight_condition` | `1` 至 `5` | 实际控制战况点击。 | -| `formation` | `1` 至 `5` | 实际控制阵型点击。 | -| `repair_mode` | `1` 中破修、`2` 大破修、`3` 不触发快速修理 | 有效,但列表未逐槽执行。 | -| `proceed_stop` | `1` 中破停止、`2` 大破停止、`3` 不因当前血量停止 | 支持逐槽判断;当前血量枚举最高为 `2`,所以阈值 `3` 实际不会触发停止。 | - -## HTTP API 审计结果 - -API 与 YAML 目前也不一致: - -- `NodeDecisionRequest.enemy_rules` 会被 API 接受,但 `build_combat_plan()` 没有复制到 - `NodeDecision`,因此通过直接 API plan 传入时不生效。 -- YAML 支持的 `enemy_formation_rules`、导弹支援和三个 SL 字段没有出现在 - `NodeDecisionRequest` 中。 -- `CombatPlanRequest.event_name` 会被 API 接受,但 `build_combat_plan()` 没有写入 - `CombatPlan.event_name`。 -- `CombatPlanRequest.map` 接受 `1a/1b` 字符串,但 `build_combat_plan()` 没有调用 - `parse_map_value()`;直接 API 路径不会拆出 `map_id` 和 `entrance`。 -- API 的 `fight_condition` 是整数,serializer 没有转换成 `FightCondition`,而执行器会访问 - `.value`,直接 API plan 路径存在类型错误风险。 -- `fleet_rules` 会由 task route 单独传给 Runner,因此实际生效,但没有进入 - `CombatPlan`,与 YAML 路径不是同一模型。 - -## 进度 - -- [x] 确认 GUI YAML 的舰队字段和转换位置。 -- [x] 确认 main 后端原有 YAML 约束范围。 -- [x] 审计 `combat-engine.md` 中的 CombatPlan 和 NodeDecision 字段。 -- [x] 追踪 `min_level`、`max_level`、`ship_type` 到实际选船逻辑。 -- [x] 找出文档、YAML、API 之间的缺失字段和命名差异。 -- [x] 复核后端未执行字段在 GUI 中的用途。 -- [x] 验证密苏里战列型和导战型的当前舰种识别能力。 -- [x] 确认国籍筛选当前只存在于 GUI。 -- [x] 将后端 `fleet_presets` 解析收缩为列表校验和三项基础整理。 -- [x] 审计上游活动 YAML 新格式及其对统一契约的影响。 -- [ ] 确认统一后的正式字段和 Schema 所有权。 -- [ ] 修改 AutoWSGR-GUI 的模型、编辑器、保存和 API 转换。 -- [ ] 修改 AutoWSGR 的 YAML parser、API schema 和 serializer。 -- [ ] 迁移两个项目中的内置 YAML。 -- [ ] 增加同一份样例在 GUI、YAML parser、API 三条路径上的契约测试。 -- [ ] 更新 `docs/architecture/combat-engine.md` 和使用文档。 - -## 验收标准 - -1. 同一份 YAML 通过 GUI 加载和后端直接读取时得到相同的运行时舰队规则。 -2. 不允许存在“校验通过但执行时未使用”的公开字段。 -3. 不允许未知字段被静默忽略。 -4. `min_level`、`max_level` 和 `ship_type` 对已有舰队与新选舰船采用一致的验证语义。 -5. 已在 GUI 或后端使用的字段保持原有语义。 -6. GUI 与后端使用同一份自动化 Schema 契约测试。 -7. 普通和活动 YAML 在 GUI、后端 YAML parser、HTTP API 三条路径中得到相同的 - `chapter`、`map_id`、`entrance` 和 `event_name`。 From b3f413cc79623eb0ca115cef496c54613f361045 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:55:20 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20YAML=20?= =?UTF-8?q?=E8=88=B0=E9=98=9F=E8=A7=84=E5=88=99=E4=B8=8E=E6=8D=A2=E8=88=B9?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/combat/plan.py | 80 +++++- autowsgr/ops/normal_fight.py | 30 +- autowsgr/server/__init__.py | 4 + autowsgr/server/schemas.py | 131 +++++++-- autowsgr/server/serializers.py | 11 +- autowsgr/ui/battle/fleet_change/_change.py | 312 +++++++++++++-------- autowsgr/ui/choose_ship_page.py | 216 ++++++++------ testing/combat/test_combat.py | 124 +++++++- testing/ops/test_normal_fight_unit.py | 54 ++++ testing/test_server_schemas.py | 126 +++++++++ testing/ui/battle_preparation/test_unit.py | 125 ++++++++- testing/ui/test_choose_ship_page.py | 182 ++++++++++++ 12 files changed, 1139 insertions(+), 256 deletions(-) create mode 100644 testing/test_server_schemas.py diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 0ba1bf87..d1770c93 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -321,7 +321,7 @@ def _trim_text(value: Any) -> Any: @classmethod def _normalize_preset_slot(cls, raw_slot: Any) -> Any: - """整理一个舰队槽位,并按填写顺序去除重复候选。""" + """整理主选和位置级备选,并兼容旧字符串候选。""" if isinstance(raw_slot, str): return raw_slot.strip() if not isinstance(raw_slot, dict): @@ -331,15 +331,79 @@ def _normalize_preset_slot(cls, raw_slot: Any) -> Any: key: cls._trim_text(value) for key, value in raw_slot.items() } - candidates = result.get('candidates') - if isinstance(candidates, list): - candidates = [ - cls._trim_text(candidate) - for candidate in candidates - ] - result['candidates'] = list(dict.fromkeys(candidates)) + ship_types = cls._normalize_ship_types(result.get('ship_type')) + if ship_types is not None: + result['ship_type'] = ship_types + + raw_candidates = result.get('candidates') + if not isinstance(raw_candidates, list): + return result + + candidates = list(raw_candidates) + if not isinstance(result.get('name'), str) or not result['name']: + primary_index = next( + ( + index + for index, candidate in enumerate(candidates) + if isinstance(candidate, str) and candidate.strip() + ), + None, + ) + if primary_index is not None: + result['name'] = candidates.pop(primary_index).strip() + + shared = { + key: result[key] + for key in ('ship_type', 'min_level', 'max_level') + if result.get(key) is not None + } + normalized_candidates: list[dict[str, Any]] = [] + # 同名主选和备选分别承担严格、宽泛规则,只去除备选队列内部的重复项。 + seen: set[str] = set() + for candidate in candidates: + if isinstance(candidate, str): + rule = {'name': candidate.strip(), **copy.deepcopy(shared)} + else: + rule = cls._normalize_ship_rule(candidate) + if not isinstance(rule, dict): + continue + name = rule.get('name') + if not isinstance(name, str) or not name or name in seen: + continue + normalized_candidates.append(rule) + seen.add(name) + result['candidates'] = normalized_candidates return result + @classmethod + def _normalize_ship_rule(cls, raw_rule: Any) -> Any: + """整理一艘备选舰船自己的规则。""" + if not isinstance(raw_rule, dict): + return raw_rule + + result = { + key: cls._trim_text(value) + for key, value in raw_rule.items() + } + ship_types = cls._normalize_ship_types(result.get('ship_type')) + if ship_types is not None: + result['ship_type'] = ship_types + return result + + @classmethod + def _normalize_ship_types(cls, raw: Any) -> list[str] | None: + """把旧单舰种字符串和新舰种列表统一为小写字符串列表。""" + values = [raw] if isinstance(raw, str) else raw + if not isinstance(values, list): + return None + + normalized = [ + value.strip().lower() + for value in values + if isinstance(value, str) and value.strip() + ] + return list(dict.fromkeys(normalized)) or None + @classmethod def from_yaml(cls, path: str | Path) -> CombatPlan: from autowsgr.infra.config_compat import ( diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index a36971d3..b7783d87 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -52,7 +52,11 @@ def __init__( self._plan = plan self._fleet_id = fleet_id if fleet_id is not None else plan.fleet_id self._fleet = fleet if fleet is not None else plan.fleet - self._fleet_rules = fleet_rules + self._fleet_rules = ( + fleet_rules + if fleet_rules is not None + else self._fleet_rules_from_plan(plan) + ) # 从 config 读取拆船配置 self._dock_full_destroy = ctx.config.dock_full_destroy @@ -91,8 +95,17 @@ def __init__( self._ship_acquired_count: int | None = None self._fleet_ships: list[Ship] | None = None + @staticmethod + def _fleet_rules_from_plan(plan: CombatPlan) -> list[Any] | None: + """未传接口覆盖值时,使用计划中第一套舰队预设。""" + if not plan.fleet_presets: + return None + ships = plan.fleet_presets[0].get('ships') + return ships if isinstance(ships, list) else None + @staticmethod def _primary_names_from_rules(fleet_rules: list[Any] | None) -> list[str | None] | None: + """读取每个槽位显式声明的主选舰名。""" if not fleet_rules: return None @@ -108,15 +121,24 @@ def _normalize_name(value: object) -> str | None: names.append(_normalize_name(slot)) continue - candidates = None if isinstance(slot, dict): + name = slot.get('name') candidates = slot.get('candidates') else: + name = getattr(slot, 'name', None) candidates = getattr(slot, 'candidates', None) - if isinstance(candidates, list) and len(candidates) > 0: - names.append(_normalize_name(candidates[0])) + normalized_name = _normalize_name(name) + if normalized_name is not None: + names.append(normalized_name) continue + + # 旧规则没有 name,candidates 的第一个字符串才是主选。 + if isinstance(candidates, list) and len(candidates) > 0: + legacy_name = candidates[0] + if isinstance(legacy_name, str): + names.append(_normalize_name(legacy_name)) + continue names.append(None) return names diff --git a/autowsgr/server/__init__.py b/autowsgr/server/__init__.py index 57579ca7..4178a157 100644 --- a/autowsgr/server/__init__.py +++ b/autowsgr/server/__init__.py @@ -15,6 +15,8 @@ from .main import app from .schemas import ( CombatPlanRequest, + FleetRuleRequest, + FleetShipRuleRequest, NodeDecisionRequest, TaskStartRequest, TaskStatusResponse, @@ -24,6 +26,8 @@ __all__ = [ 'CombatPlanRequest', + 'FleetRuleRequest', + 'FleetShipRuleRequest', 'NodeDecisionRequest', 'TaskManager', 'TaskStartRequest', diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index 48ceb3e1..5c55c502 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -83,6 +83,14 @@ class NodeDecisionRequest(BaseModel): description='停止前进条件 (6个位置)', ) detour: bool = Field(default=False, description='是否迂回') + long_missile_support: bool = Field( + default=False, + description='是否开启远程导弹支援', + ) + SL_when_detour_fails: bool = Field( + default=True, + description='迂回失败时是否 SL', + ) enemy_rules: list[list[str]] | None = Field( default=None, description='索敌规则', @@ -91,40 +99,54 @@ class NodeDecisionRequest(BaseModel): model_config = {'extra': 'forbid'} -class FleetRuleRequest(BaseModel): - """编队槽位候选规则。""" +class FleetShipRuleRequest(BaseModel): + """一艘主选或备选舰船自己的选船规则。""" - candidates: list[str] = Field(min_length=1, description='候选舰船名(按优先级)') + name: str = Field(description='舰船名') search_name: str | None = Field(default=None, description='选船搜索关键词(用于同名舰船区分)') - ship_type: str | None = Field(default=None, description='舰种约束(如 cl/cav/ss)') + ship_type: list[str] | None = Field(default=None, description='允许的舰种列表(如 [ss, ssg])') min_level: int | None = Field(default=None, ge=1, description='等级下限(含)') max_level: int | None = Field(default=None, ge=1, description='等级上限(含)') - @field_validator('candidates') + @field_validator('name') @classmethod - def _validate_candidates(cls, value: list[str]) -> list[str]: - normalized = [name.strip() for name in value if name and name.strip()] - if len(normalized) == 0: - raise ValueError('candidates 不能为空') + def _validate_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError('name 不能为空') return normalized - @field_validator('ship_type') + @field_validator('search_name') @classmethod - def _validate_ship_type(cls, value: str | None) -> str | None: + def _validate_search_name(cls, value: str | None) -> str | None: if value is None: return None + return value.strip() or None - normalized = value.strip().lower() - if not normalized: + @field_validator('ship_type', mode='before') + @classmethod + def _validate_ship_type(cls, value: Any) -> list[str] | None: + if value is None or value == '': return None - if normalized not in _ALLOWED_SHIP_TYPE_CODES: - allowed = ', '.join(sorted(_ALLOWED_SHIP_TYPE_CODES)) - raise ValueError(f'ship_type 不合法: {value!r}, 可选值: {allowed}') + values = [value] if isinstance(value, str) else value + if not isinstance(values, list) or not values: + raise ValueError('ship_type 必须是非空字符串列表') + + normalized: list[str] = [] + for ship_type in values: + if not isinstance(ship_type, str) or not ship_type.strip(): + raise ValueError('ship_type 必须是非空字符串列表') + code = ship_type.strip().lower() + if code not in _ALLOWED_SHIP_TYPE_CODES: + allowed = ', '.join(sorted(_ALLOWED_SHIP_TYPE_CODES)) + raise ValueError(f'ship_type 不合法: {ship_type!r}, 可选值: {allowed}') + if code not in normalized: + normalized.append(code) return normalized @model_validator(mode='after') - def _validate_level_range(self) -> FleetRuleRequest: + def _validate_level_range(self) -> FleetShipRuleRequest: if ( self.min_level is not None and self.max_level is not None @@ -136,6 +158,81 @@ def _validate_level_range(self) -> FleetRuleRequest: model_config = {'extra': 'forbid'} +class FleetRuleRequest(FleetShipRuleRequest): + """一个槽位的主选规则及其位置级备选规则。""" + + name: str | None = Field(default=None, description='主选舰船名') + candidates: list[FleetShipRuleRequest] = Field( + default_factory=list, + description='位置级备选舰船规则(按填写顺序尝试)', + ) + + @field_validator('name') + @classmethod + def _validate_name(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None + + @model_validator(mode='before') + @classmethod + def _upgrade_legacy_candidates(cls, value: Any) -> Any: + """兼容旧 candidates 字符串列表,主选迁移到 name。""" + if not isinstance(value, dict): + return value + + result = dict(value) + raw_candidates = result.get('candidates') + if not isinstance(raw_candidates, list): + return result + + candidates = list(raw_candidates) + if not isinstance(result.get('name'), str) or not result['name'].strip(): + first_name = next( + ( + candidate + for candidate in candidates + if isinstance(candidate, str) and candidate.strip() + ), + None, + ) + if first_name is None: + return result + result['name'] = first_name + candidates.remove(first_name) + + shared = { + key: result[key] + for key in ('ship_type', 'min_level', 'max_level') + if result.get(key) is not None + } + result['candidates'] = [ + {'name': candidate, **shared} if isinstance(candidate, str) else candidate + for candidate in candidates + if not isinstance(candidate, str) or candidate.strip() + ] + return result + + @model_validator(mode='after') + def _validate_slot(self) -> FleetRuleRequest: + """无主选时只允许保留非空的位置级备选队列。""" + if self.name is not None: + return self + if len(self.candidates) == 0: + raise ValueError('位置至少需要一艘主选或备选舰船') + if any( + value is not None + for value in ( + self.search_name, + self.ship_type, + self.min_level, + self.max_level, + ) + ): + raise ValueError('没有主选 name 时不能填写主选规则') + return self + + class CombatPlanRequest(BaseModel): """作战计划请求体。""" diff --git a/autowsgr/server/serializers.py b/autowsgr/server/serializers.py index 233bea4a..411d559b 100644 --- a/autowsgr/server/serializers.py +++ b/autowsgr/server/serializers.py @@ -141,15 +141,11 @@ def convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: # noq def build_combat_plan(request: Any) -> Any: """从请求构建 CombatPlan 对象。""" from autowsgr.combat import CombatPlan, NodeDecision - from autowsgr.types import Formation, RepairMode + from autowsgr.types import RepairMode def _build_node_decision(node_req: Any) -> NodeDecision: - return NodeDecision( - formation=Formation(node_req.formation), - night=node_req.night, - proceed=node_req.proceed, - proceed_stop=[RepairMode(r) for r in node_req.proceed_stop], - detour=node_req.detour, + return NodeDecision.from_dict( + node_req.model_dump(exclude_none=True), ) node_args = {k: _build_node_decision(v) for k, v in request.node_args.items()} @@ -166,4 +162,5 @@ def _build_node_decision(node_req: Any) -> NodeDecision: selected_nodes=request.selected_nodes, default_node=_build_node_decision(request.node_defaults), nodes=node_args, + event_name=request.event_name, ) diff --git a/autowsgr/ui/battle/fleet_change/_change.py b/autowsgr/ui/battle/fleet_change/_change.py index b830cbbd..8c036aff 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -45,20 +45,34 @@ _SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') -# 描述一个槽位可以使用的舰名和筛选条件。 -class FleetSlotSelector(TypedDict, total=False): - """编队槽位规则。""" +# 描述一艘主选或备选舰船自己的筛选条件。 +class FleetShipOption(TypedDict, total=False): + """单艘舰船的选船规则。""" name: str - candidates: list[str] search_name: str - ship_type: str + ship_type: list[str] min_level: int max_level: int + relaxed_constraints: bool + + +# 描述 YAML 中一个主选槽位及其位置级备选。 +class FleetSlotRule(FleetShipOption, total=False): + """YAML 槽位规则。""" + + candidates: list[str | FleetShipOption] + + +# 智能换船内部按尝试顺序使用的完整规则列表。 +class FleetSlotSelector(TypedDict): + """后端内部槽位规则,不写回 YAML。""" + + options: list[FleetShipOption] # 一个槽位可以是固定舰名、带条件的规则或空槽。 -FleetSlotInput = str | FleetSlotSelector | None +FleetSlotInput = str | FleetSlotRule | None # 为普通出征和决战准备页提供同一套智能换船流程。 @@ -83,7 +97,7 @@ def change_fleet( # noqa: PLR0912 # Step 2:分别保存六个槽位的目标舰名和选船规则。 names: list[str | None] = [] - selectors: list[dict | None] = [] + selectors: list[FleetSlotSelector | None] = [] for raw_slot in list(ship_names)[:6]: selector = self._extract_selector(raw_slot) selectors.append(selector) @@ -91,12 +105,11 @@ def change_fleet( # noqa: PLR0912 # 字符串槽位直接使用该舰名。 if isinstance(raw_slot, str): names.append(self._normalize_ship_name(raw_slot)) - # 规则槽位先把第一个候选作为优选舰名。 + # 规则槽位从显式主选规则读取目标舰名。 elif selector is not None: - # candidates 按 YAML 中的填写顺序保存优选和备选。 - candidates = selector.get('candidates', []) - if isinstance(candidates, list) and len(candidates) > 0: - names.append(self._normalize_ship_name(candidates[0])) + options = selector['options'] + if options: + names.append(self._normalize_ship_name(options[0]['name'])) else: names.append(None) else: @@ -185,104 +198,154 @@ def _ship_identity(cls, value: object) -> str | None: normalized = cls._normalize_ship_name(value) return ship_name_identity(normalized) if normalized is not None else None - # 从一个槽位读取优选、备选、搜索名、舰种和等级条件。 + # 从一个槽位读取主选及每个备选自己的完整规则。 @classmethod - def _extract_selector(cls, slot: object | None) -> dict | None: - """返回选船页面可以直接使用的槽位规则。""" + def _extract_selector(cls, slot: object | None) -> FleetSlotSelector | None: + """把新旧槽位结构整理为内部完整规则列表。""" # 固定舰名和空槽没有额外选船规则。 if slot is None or isinstance(slot, str): return None - # 字典槽位直接读取 YAML 字段。 - if isinstance(slot, dict): - raw_candidates = slot.get('candidates') - raw_search_name = slot.get('search_name') - raw_ship_type = slot.get('ship_type') - raw_min = slot.get('min_level') - raw_max = slot.get('max_level') - raw_name = slot.get('name') - # selector 对象通过同名属性读取字段。 - else: - raw_candidates = getattr(slot, 'candidates', None) - raw_search_name = getattr(slot, 'search_name', None) - raw_ship_type = getattr(slot, 'ship_type', None) - raw_min = getattr(slot, 'min_level', None) - raw_max = getattr(slot, 'max_level', None) - raw_name = getattr(slot, 'name', None) - - # raw_values 按“name 优先、candidates 备选”的顺序合并舰名。 - raw_values: list[object] = [] - - # 有效的 name 放在候选列表首位。 - if isinstance(raw_name, str) and raw_name.strip(): - raw_values.append(raw_name) - - # candidates 紧跟在 name 后面,保留 YAML 填写顺序。 - if isinstance(raw_candidates, list): - raw_values.extend(raw_candidates) - - # candidates 保存去空格后的原始舰名,交给选船页面使用。 - candidates: list[str] = [] + raw_candidates = cls._rule_field(slot, 'candidates') + candidates = list(raw_candidates) if isinstance(raw_candidates, list) else [] + + # 主选显式读取 name;旧结构缺少 name 时才取第一个字符串候选。 + primary = cls._normalize_option(slot) + if primary is None: + primary_index = next( + ( + index + for index, candidate in enumerate(candidates) + if isinstance(candidate, str) and candidate.strip() + ), + None, + ) + if primary_index is not None: + primary = cls._normalize_option( + candidates.pop(primary_index), + inherited=slot, + inherit_search=True, + ) - # seen 保存舰船组身份,防止同一艘船的不同名称重复。 - seen: set[str] = set() - for value in raw_values: - candidate = str(value).strip() - normalized = cls._normalize_ship_name(candidate) - identity = cls._ship_identity(normalized) - if candidate and normalized and identity and identity not in seen: - candidates.append(candidate) - seen.add(identity) + # 没有主选时,结构化 candidates 仍是该位置的完整宽泛候选队列。 + options = [primary] if primary is not None else [] - # 没有舰名候选时无法形成有效选船规则。 - if not candidates: + # 旧字符串备选继承槽位约束;新对象备选只使用自己的规则。 + for candidate in candidates: + option = cls._normalize_option( + candidate, + inherited=slot if isinstance(candidate, str) else None, + ) + if option is None: + continue + option['relaxed_constraints'] = True + options.append(option) + return {'options': options} if options else None + + @staticmethod + def _rule_field(rule: object, field: str) -> object: + """同时读取字典规则和 Pydantic 请求对象。""" + if isinstance(rule, dict): + return rule.get(field) + return getattr(rule, field, None) + + @classmethod + def _normalize_option( + cls, + raw: object, + *, + inherited: object | None = None, + inherit_search: bool = False, + ) -> FleetShipOption | None: + """整理一艘舰船规则,旧字符串可继承槽位公共约束。""" + raw_name = raw if isinstance(raw, str) else cls._rule_field(raw, 'name') + if not isinstance(raw_name, str) or not raw_name.strip(): return None - # selector 是最终传给选船页面的规则。 - selector: dict[str, object] = {'candidates': candidates} - if isinstance(raw_search_name, str) and raw_search_name.strip(): - selector['search_name'] = raw_search_name.strip() - if isinstance(raw_ship_type, str) and raw_ship_type.strip(): - selector['ship_type'] = raw_ship_type.strip().lower() + option: FleetShipOption = {'name': raw_name.strip()} + source = raw if not isinstance(raw, str) else inherited + if source is None: + return option + + if not isinstance(raw, str) or inherit_search: + raw_search_name = cls._rule_field(source, 'search_name') + if isinstance(raw_search_name, str) and raw_search_name.strip(): + option['search_name'] = raw_search_name.strip() + + raw_ship_types = cls._rule_field(source, 'ship_type') + values = [raw_ship_types] if isinstance(raw_ship_types, str) else raw_ship_types + if isinstance(values, list): + ship_types = list( + dict.fromkeys( + value.strip().lower() + for value in values + if isinstance(value, str) and value.strip() + ), + ) + if ship_types: + option['ship_type'] = ship_types + + raw_min = cls._rule_field(source, 'min_level') + raw_max = cls._rule_field(source, 'max_level') if isinstance(raw_min, int) and raw_min > 0: - selector['min_level'] = raw_min + option['min_level'] = raw_min if isinstance(raw_max, int) and raw_max > 0: - selector['max_level'] = raw_max - return selector + option['max_level'] = raw_max + if cls._rule_field(source, 'relaxed_constraints') is True: + option['relaxed_constraints'] = True + return option - # 按“已分配舰名优先、原候选随后”的顺序生成本槽候选列表。 + # 按“已分配舰名优先、其余规则随后”的顺序生成本槽完整规则。 @classmethod - def _slot_candidates(cls, name: str | None, selector: dict | None) -> list[str]: - out: list[str] = [] - seen: set[str] = set() + def _slot_options( + cls, + name: str | None, + selector: FleetSlotSelector | dict | None, + ) -> list[FleetShipOption]: normalized_name = cls._normalize_ship_name(name) - name_identity = cls._ship_identity(normalized_name) - - # 已分配舰名存在时,将它放在候选列表第一位。 - if normalized_name and name_identity: - out.append(normalized_name) - seen.add(name_identity) - - # 有 selector 时,继续补充本槽位的原始候选。 - if selector is not None: - raw = selector.get('candidates') - - # candidates 必须是列表才逐项读取。 - if isinstance(raw, list): - for value in raw: - normalized = cls._normalize_ship_name(value) - identity = cls._ship_identity(normalized) - if normalized and identity and identity not in seen: - out.append(normalized) - seen.add(identity) - return out + if selector is None: + return [{'name': normalized_name}] if normalized_name else [] + + raw_options = selector.get('options') + if not isinstance(raw_options, list): + legacy_selector = cls._extract_selector(selector) + raw_options = legacy_selector['options'] if legacy_selector is not None else [] + + options = [ + option + for raw_option in raw_options + if (option := cls._normalize_option(raw_option)) is not None + ] + target_identity = cls._ship_identity(normalized_name) + options.sort( + key=lambda option: cls._ship_identity(option['name']) != target_identity, + ) + + return options + + @classmethod + def _slot_candidates( + cls, + name: str | None, + selector: FleetSlotSelector | dict | None, + ) -> list[str]: + """返回本槽按尝试顺序排列的标准舰名。""" + candidates: list[str] = [] + seen: set[str] = set() + for option in cls._slot_options(name, selector): + normalized = cls._normalize_ship_name(option['name']) + identity = cls._ship_identity(normalized) + if normalized is not None and identity is not None and identity not in seen: + candidates.append(normalized) + seen.add(identity) + return candidates # 为六个槽位挑选互不重复的目标舰名,冲突时自动尝试备选。 @classmethod def _assign_unique_targets( cls, names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> list[str | None] | None: """为每个非空槽位分配唯一舰名,候选重叠时按优先级回溯。""" # options 保存六个槽位各自按优先级排列的候选舰名。 @@ -330,45 +393,60 @@ def _matches_search_name(cls, current_name: str | None, raw_search_name: object) return cls._ship_identity(current_name) == cls._ship_identity(search_name) + @classmethod + def _option_for_name( + cls, + name: str | None, + selector: FleetSlotSelector | dict | None, + ) -> FleetShipOption | None: + """返回与实际舰名对应的独立规则。""" + identity = cls._ship_identity(name) + return next( + ( + option + for option in cls._slot_options(name, selector) + if cls._ship_identity(option['name']) == identity + ), + None, + ) + # 从本槽候选中排除队内同名舰,并返回实际可用于选船的规则。 @classmethod def _select_available_candidate( cls, current: list[str | None], name: str | None, - selector: dict | None, + selector: FleetSlotSelector | dict | None, *, slot_to_replace: int | None = None, - ) -> tuple[str | None, dict | None]: + ) -> tuple[str | None, FleetSlotSelector | None]: """返回第一个未被其他槽位占用的候选舰名。""" # 目标舰名为空时,本槽不需要选船。 if name is None: return None, None - # candidates 是本槽位按优先级排列的标准舰名。 - candidates = cls._slot_candidates(name, selector) + # options 是本槽位按优先级排列的完整选船规则。 + options = cls._slot_options(name, selector) # occupied 保存队内其他槽位已经占用的舰船组身份。 occupied = { cls._ship_identity(ship) for idx, ship in enumerate(current) if ship is not None and idx != slot_to_replace } - # available 保留当前舰队中尚未占用的候选。 + # available 保留当前舰队中尚未占用的完整规则。 available = [ - candidate for candidate in candidates if cls._ship_identity(candidate) not in occupied + option for option in options if cls._ship_identity(option['name']) not in occupied ] if len(available) == 0: return None, None - chosen = available[0] + chosen = cls._normalize_ship_name(available[0]['name']) if selector is None: return chosen, None - # narrowed_selector 只把未占用候选交给选船页面。 - narrowed_selector = dict(selector) - narrowed_selector['candidates'] = available - return chosen, narrowed_selector + # 选船页面按顺序尝试未占用规则,各备选使用自己的约束。 + return chosen, {'options': available} # 将当前舰队成员与目标槽位一对一匹配,找出可以直接保留的舰船。 @classmethod @@ -376,7 +454,7 @@ def _match_existing_members( cls, current: list[str | None], desired: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> tuple[list[bool], set[int]]: """在当前舰队与目标槽位之间做一对一匹配。 @@ -396,8 +474,9 @@ def _match_existing_members( # 判断一艘当前舰船能否满足指定目标槽位。 def matches(slot: int, ship: str | None) -> bool: selector = selectors[slot] + option = cls._option_for_name(desired[slot], selector) return cls._ship_identity(ship) == cls._ship_identity(desired[slot]) and ( - selector is None or cls._matches_search_name(ship, selector.get('search_name')) + option is None or cls._matches_search_name(ship, option.get('search_name')) ) # 第一轮优先保留已经位于正确槽位的舰船。 @@ -432,22 +511,19 @@ def _slot_matches( cls, current_name: str | None, target: str | None, - selector: dict | None, + selector: FleetSlotSelector | dict | None, ) -> bool: # 目标为空时,只有当前槽也为空才算匹配。 if target is None: return current_name is None if selector is None: return cls._ship_identity(current_name) == cls._ship_identity(target) - candidate_identities = { - cls._ship_identity(candidate) for candidate in cls._slot_candidates(target, selector) - } - return ( - cls._matches_search_name( - current_name, - selector.get('search_name'), - ) - and cls._ship_identity(current_name) in candidate_identities + option = cls._option_for_name(current_name, selector) + if option is None: + return False + return cls._matches_search_name( + current_name, + option.get('search_name'), ) # 验证当前六个槽位是否完整满足目标,并拒绝队内同名舰。 @@ -456,7 +532,7 @@ def _validate_with_selector( cls, current: list[str | None], desired: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> bool: members = [cls._ship_identity(name) for name in current if name is not None] if len(members) != len(set(members)): @@ -470,7 +546,7 @@ def _find_wrong_slots( cls, current: list[str | None], names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> list[int]: """返回所有不符合目标规则的槽位下标。""" return [i for i in range(6) if not cls._slot_matches(current[i], names[i], selectors[i])] @@ -480,7 +556,7 @@ def _replace_target( self, current: list[str | None], names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], target_slot: int, ship_slot: int | None = None, ) -> None: @@ -520,7 +596,7 @@ def _full_align( self, current: list[str | None], names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> None: """首次将当前成员调整成目标成员集合。""" # ok 标记当前可保留位置,matched_slots 标记已满足的目标槽位。 @@ -571,7 +647,7 @@ def _local_fix( self, current: list[str | None], names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotSelector | dict | None], ) -> None: """只修正本轮识别出的错误槽位。""" # wrong 保存所有需要替换、补充或移除的槽位。 diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index ed77e354..f9702925 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -197,7 +197,74 @@ def click_remove(self) -> None: _log.debug('[UI] 选船 → 移除舰船') self._ctrl.click(*CLICK_REMOVE_SHIP) - def change_single_ship( # noqa: C901, PLR0912 + @classmethod + def _selection_options( # noqa: PLR0912 + cls, + name: str, + selector: dict | None, + ) -> list[dict[str, object]]: + """整理智能换船传入的独立主选和备选规则。""" + if selector is None: + return [{'name': name}] + + raw_options = selector.get('options') + if not isinstance(raw_options, list): + raw_candidates = selector.get('candidates') + raw_options = raw_candidates if isinstance(raw_candidates, list) else [name] + + target_identity = cls._normalize_ship_name(name) + options: list[dict[str, object]] = [] + for raw_option in raw_options: + if isinstance(raw_option, str): + option: dict[str, object] = {'name': raw_option.strip()} + source = selector + elif isinstance(raw_option, dict): + raw_name = raw_option.get('name') + if not isinstance(raw_name, str): + continue + option = {'name': raw_name.strip()} + source = raw_option + else: + continue + + if not option['name']: + continue + + if ( + not isinstance(raw_option, str) + or cls._normalize_ship_name(raw_option) == target_identity + ): + raw_search = source.get('search_name') + if isinstance(raw_search, str) and raw_search.strip(): + option['search_name'] = raw_search.strip() + + raw_ship_types = source.get('ship_type') + values = [raw_ship_types] if isinstance(raw_ship_types, str) else raw_ship_types + if isinstance(values, list): + ship_types = list( + dict.fromkeys( + value.strip().lower() + for value in values + if isinstance(value, str) and value.strip() + ), + ) + if ship_types: + option['ship_type'] = ship_types + + for field in ('min_level', 'max_level'): + value = source.get(field) + if isinstance(value, int) and value > 0: + option[field] = value + if source.get('relaxed_constraints') is True: + option['relaxed_constraints'] = True + options.append(option) + + options.sort( + key=lambda option: cls._normalize_ship_name(str(option['name'])) != target_identity, + ) + return options or [{'name': name}] + + def change_single_ship( self, name: str | None, *, @@ -218,13 +285,9 @@ def change_single_ship( # noqa: C901, PLR0912 常规出征为 ``True`` (默认), 决战为 ``False`` (决战选船界面没有搜索框)。 selector: - 可选规则,支持 ``candidates`` / ``search_name`` / - ``ship_type`` / ``min_level`` / ``max_level``。 - 其中 ``search_name`` 用于指定搜索框关键字(仅在 - ``use_search=True`` 且界面存在搜索框时生效), - ``candidates`` 用于限定允许点击的舰船名集合, - ``ship_type`` 用于按舰种筛选同名舰船, - ``min_level`` / ``max_level`` 用于按等级范围筛选。 + 智能换船内部规则。``options`` 中每一项分别保存舰名、 + 搜索名、允许舰种和等级范围;旧 ``candidates`` 字符串 + 列表仍兼容读取。 Returns ------- @@ -240,81 +303,36 @@ def change_single_ship( # noqa: C901, PLR0912 _log.warning('[UI] 未提供 OCR 引擎, 无法识别选船列表') return None - candidates = [name] - search_name: str | None = None - ship_type: str | None = None - min_level: int | None = None - max_level: int | None = None - - if isinstance(selector, dict): - raw_candidates = selector.get('candidates') - if isinstance(raw_candidates, list): - parsed = [str(v).strip() for v in raw_candidates if str(v).strip()] - if parsed: - candidates = parsed - raw_min = selector.get('min_level') - raw_max = selector.get('max_level') - raw_search = selector.get('search_name') - raw_ship_type = selector.get('ship_type') - if isinstance(raw_search, str) and raw_search.strip(): - search_name = self._normalize_search_keyword(raw_search) - if isinstance(raw_ship_type, str) and raw_ship_type.strip(): - ship_type = raw_ship_type.strip().lower() - if isinstance(raw_min, int) and raw_min > 0: - min_level = raw_min - if isinstance(raw_max, int) and raw_max > 0: - max_level = raw_max - - if use_search and search_name: - self.ensure_search_box() - self.input_ship_name(search_name) - self.ensure_dismiss_keyboard() - matched = self._click_ship_in_list( - name, - ship_type=ship_type, - min_level=min_level, - max_level=max_level, - ) - if matched is not None: - self._wait_leave_current_page() - return matched - - for candidate in candidates: - search_candidate = self._normalize_search_keyword(candidate) + options = self._selection_options(name, selector) + for option in options: + candidate = str(option['name']) + raw_search_name = option.get('search_name', candidate) + search_name = self._normalize_search_keyword(str(raw_search_name)) + ship_types = option.get('ship_type') + min_level = option.get('min_level') + max_level = option.get('max_level') + relaxed_constraints = option.get('relaxed_constraints') is True if use_search: self.ensure_search_box() - self.input_ship_name(search_candidate) + self.input_ship_name(search_name) self.ensure_dismiss_keyboard() matched = self._click_ship_in_list( candidate, - ship_type=ship_type, - min_level=min_level, - max_level=max_level, + ship_type=ship_types if isinstance(ship_types, list) else None, + min_level=min_level if isinstance(min_level, int) else None, + max_level=max_level if isinstance(max_level, int) else None, + relaxed_constraints=relaxed_constraints, ) if matched is not None: self._wait_leave_current_page() return matched - level_hint = '' - if min_level is not None or max_level is not None: - if min_level is not None and max_level is not None: - level_hint = f' (等级限制: {min_level}-{max_level})' - elif min_level is not None: - level_hint = f' (等级限制: >= {min_level})' - else: - level_hint = f' (等级限制: <= {max_level})' - - ship_type_hint = '' - if ship_type is not None: - ship_type_hint = f' (舰种限制: {ship_type})' - + candidates = [option['name'] for option in options] _log.error( - '[UI] 未在选船列表中找到可用候选: {}{}{}', + '[UI] 未在选船列表中找到满足独立规则的候选: {}', candidates, - level_hint, - ship_type_hint, ) - raise RuntimeError(f'未找到满足条件的目标舰船: {candidates}{level_hint}{ship_type_hint}') + raise RuntimeError(f'未找到满足条件的目标舰船: {candidates}') @staticmethod def _normalize_hit_entry(hit: object) -> tuple[str, float, float, float]: @@ -363,13 +381,14 @@ def _is_level_in_range(level: int | None, min_level: int | None, max_level: int return False return not (max_level is not None and level > max_level) - def _click_ship_in_list( # noqa: PLR0912 + def _click_ship_in_list( # noqa: C901, PLR0912 self, name: str, *, - ship_type: str | None = None, + ship_type: list[str] | None = None, min_level: int | None = None, max_level: int | None = None, + relaxed_constraints: bool = False, ) -> str | None: """在选船列表页使用 DLL 定位 + OCR 识别舰船名并点击目标。 @@ -380,6 +399,9 @@ def _click_ship_in_list( # noqa: PLR0912 name: 目标舰船名。 匹配时会先做舰名归一化(如去除“·改”与尾部括号别名)后再比较。 + relaxed_constraints: + 备选舰船使用。舰名命中后只尝试一次等级和舰种校验, + 约束识别失败或不匹配时仍按舰名选择。 Returns ------- @@ -405,16 +427,25 @@ def _click_ship_in_list( # noqa: PLR0912 deduplicate_by_name=False, include_row_key=True, ) - except LevelOCRRetryNeededError as exc: - _log.warning( - '[UI] 等级 OCR 噪声过高,触发重新识别 (第 {}/{} 次)', - attempt + 1, - _OCR_MAX_ATTEMPTS, - ) - if attempt >= _OCR_MAX_ATTEMPTS - 1: - raise RuntimeError('等级 OCR 噪声过高,重试后仍失败') from exc - time.sleep(0.3) - continue + except LevelOCRRetryNeededError: + if relaxed_constraints: + _log.warning( + '[UI] 备选舰等级 OCR 失败,继续按舰名校验', + ) + raw_levels = [] + else: + _log.warning( + '[UI] 等级 OCR 噪声过高,触发重新识别 (第 {}/{} 次)', + attempt + 1, + _OCR_MAX_ATTEMPTS, + ) + if attempt >= _OCR_MAX_ATTEMPTS - 1: + _log.error( + '[UI] 等级 OCR 噪声过高,本规则校验失败', + ) + return None + time.sleep(0.3) + continue else: raw_hits = locate_ship_rows(self._ctx.ocr, screen) raw_levels = [] @@ -447,7 +478,8 @@ def _click_ship_in_list( # noqa: PLR0912 min_level if min_level is not None else '-', max_level if max_level is not None else '-', ) - continue + if not relaxed_constraints: + continue if ship_type is not None: detected_ship_type = self._detect_ship_type_near_hit( @@ -463,7 +495,8 @@ def _click_ship_in_list( # noqa: PLR0912 detected_ship_type if detected_ship_type is not None else '未知', ship_type, ) - continue + if not relaxed_constraints: + continue _log.info( "[UI] 选船 DLL+OCR -> '{}' (第 {}/{} 次), 点击 ({:.3f}, {:.3f})", @@ -533,13 +566,18 @@ def _extract_ship_type_from_text(text: str) -> str | None: return None @staticmethod - def _is_ship_type_in_rule(detected: str | None, expected: str) -> bool: + def _is_ship_type_in_rule( + detected: str | None, + expected: str | list[str], + ) -> bool: if detected is None: return False - rule = expected.strip().lower() - if rule == 'ss_or_ssg': - return detected in {'ss', 'ssg'} - return detected == rule + rules = [expected] if isinstance(expected, str) else expected + normalized = {rule.strip().lower() for rule in rules} + return detected in normalized or ( + 'ss_or_ssg' in normalized + and detected in {'ss', 'ssg'} + ) @staticmethod def _normalize_search_keyword(name: str) -> str: diff --git a/testing/combat/test_combat.py b/testing/combat/test_combat.py index 31e9e03f..9ac9c356 100644 --- a/testing/combat/test_combat.py +++ b/testing/combat/test_combat.py @@ -454,7 +454,7 @@ def test_empty_presets_is_preserved(self): assert plan.fleet_presets == [] def test_preset_content_is_normalized(self): - """整理名称和槽位,并按顺序去除重复候选。""" + """旧字符串候选迁移为显式主选和完整备选规则。""" plan = CombatPlan.from_dict( { 'fleet_presets': [ @@ -479,9 +479,127 @@ def test_preset_content_is_normalized(self): 'ships': [ '飞龙·改', { - 'candidates': ['岛风', '黑潮'], - 'ship_type': 'dd', + 'name': '岛风', + 'candidates': [ + { + 'name': '黑潮', + 'ship_type': ['dd'], + 'min_level': 100, + }, + { + 'name': '岛风', + 'ship_type': ['dd'], + 'min_level': 100, + }, + ], + 'ship_type': ['dd'], + 'min_level': 100, + }, + ], + }, + ] + + def test_independent_candidate_rules_are_preserved(self): + """主选和每个备选分别保留自己的舰种及等级范围。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': '潜艇队', + 'ships': [ + { + 'name': 'U-47', + 'ship_type': ['SS', 'SSG'], + 'min_level': 100, + 'max_level': 110, + 'candidates': [ + { + 'name': 'U-96', + 'ship_type': ['SS'], + 'min_level': 90, + 'max_level': 105, + }, + { + 'name': 'U-47', + 'ship_type': ['SS'], + 'min_level': 100, + 'max_level': 110, + }, + ], + }, + ], + }, + ], + }, + ) + + assert plan.fleet_presets == [ + { + 'name': '潜艇队', + 'ships': [ + { + 'name': 'U-47', + 'ship_type': ['ss', 'ssg'], 'min_level': 100, + 'max_level': 110, + 'candidates': [ + { + 'name': 'U-96', + 'ship_type': ['ss'], + 'min_level': 90, + 'max_level': 105, + }, + { + 'name': 'U-47', + 'ship_type': ['ss'], + 'min_level': 100, + 'max_level': 110, + }, + ], + }, + ], + }, + ] + + def test_candidate_only_slots_are_preserved(self): + """结构化纯备选位置不把第一候选提升为严格主选。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': '纯备选', + 'ships': [ + { + 'candidates': [ + {'name': ' 胡德 ', 'ship_type': ['BC']}, + { + 'name': '扶桑', + 'ship_type': ['BB'], + 'min_level': 80, + 'max_level': 110, + }, + ], + }, + ], + }, + ], + }, + ) + + assert plan.fleet_presets == [ + { + 'name': '纯备选', + 'ships': [ + { + 'candidates': [ + {'name': '胡德', 'ship_type': ['bc']}, + { + 'name': '扶桑', + 'ship_type': ['bb'], + 'min_level': 80, + 'max_level': 110, + }, + ], }, ], }, diff --git a/testing/ops/test_normal_fight_unit.py b/testing/ops/test_normal_fight_unit.py index e128dd5a..a7bc3523 100644 --- a/testing/ops/test_normal_fight_unit.py +++ b/testing/ops/test_normal_fight_unit.py @@ -35,6 +35,60 @@ def test_failure_stops_fight(self): _require_fleet_change(False, 'fleet') +class TestFleetPresetRules: + def test_plan_preset_is_used_without_api_override(self): + ships = [ + { + 'name': 'U-47', + 'candidates': [{'name': 'U-96'}], + }, + ] + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': '潜艇队', + 'ships': ships, + }, + ], + }, + ) + + runner = NormalFightRunner(_make_ctx(), plan) + + assert runner._fleet_rules == ships + assert runner._primary_names_from_rules(ships) == ['U-47'] + + def test_api_rules_override_plan_preset(self): + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': '计划编队', + 'ships': [{'name': 'U-47'}], + }, + ], + }, + ) + override = [{'name': '岛风'}] + + runner = NormalFightRunner(_make_ctx(), plan, fleet_rules=override) + + assert runner._fleet_rules == override + + def test_candidate_only_slot_has_no_fixed_primary_name(self): + rules = [ + { + 'candidates': [ + {'name': '胡德'}, + {'name': '扶桑'}, + ], + }, + ] + + assert NormalFightRunner._primary_names_from_rules(rules) == [None] + + class TestEventNormalMerge: """chapter (E/H vs 数字) 决定导航分支与 plan.mode。""" diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py new file mode 100644 index 00000000..67354fa5 --- /dev/null +++ b/testing/test_server_schemas.py @@ -0,0 +1,126 @@ +"""后端编队请求契约的定向测试。""" + +import pytest +from pydantic import ValidationError + +from autowsgr.server.schemas import FleetRuleRequest + + +def test_new_fleet_rule_keeps_independent_candidates(): + rule = FleetRuleRequest.model_validate( + { + 'name': 'U-47', + 'ship_type': ['SS', 'SSG'], + 'min_level': 100, + 'max_level': 110, + 'candidates': [ + { + 'name': 'U-96', + 'ship_type': ['SS'], + 'min_level': 90, + 'max_level': 105, + }, + { + 'name': 'U-47', + 'ship_type': ['SS'], + 'min_level': 100, + 'max_level': 110, + }, + ], + }, + ) + + assert rule.model_dump(exclude_none=True) == { + 'name': 'U-47', + 'ship_type': ['ss', 'ssg'], + 'min_level': 100, + 'max_level': 110, + 'candidates': [ + { + 'name': 'U-96', + 'ship_type': ['ss'], + 'min_level': 90, + 'max_level': 105, + }, + { + 'name': 'U-47', + 'ship_type': ['ss'], + 'min_level': 100, + 'max_level': 110, + }, + ], + } + + +def test_candidate_only_fleet_rule_is_valid(): + rule = FleetRuleRequest.model_validate( + { + 'candidates': [ + {'name': ' 胡德 ', 'ship_type': ['BC']}, + {'name': '扶桑', 'min_level': 80, 'max_level': 110}, + ], + }, + ) + + assert rule.model_dump(exclude_none=True) == { + 'candidates': [ + {'name': '胡德', 'ship_type': ['bc']}, + {'name': '扶桑', 'min_level': 80, 'max_level': 110}, + ], + } + + +def test_empty_fleet_slot_is_rejected(): + with pytest.raises( + ValidationError, + match='位置至少需要一艘主选或备选舰船', + ): + FleetRuleRequest.model_validate({}) + + +def test_candidate_only_slot_rejects_primary_constraints(): + with pytest.raises( + ValidationError, + match='没有主选 name 时不能填写主选规则', + ): + FleetRuleRequest.model_validate( + { + 'ship_type': ['BB'], + 'candidates': [{'name': '胡德'}], + }, + ) + + +def test_legacy_candidate_names_are_migrated(): + rule = FleetRuleRequest.model_validate( + { + 'candidates': [' 岛风 ', '雪风'], + 'ship_type': 'DD', + 'min_level': 80, + }, + ) + + assert rule.name == '岛风' + assert rule.ship_type == ['dd'] + assert [candidate.model_dump(exclude_none=True) for candidate in rule.candidates] == [ + { + 'name': '雪风', + 'ship_type': ['dd'], + 'min_level': 80, + }, + ] + + +def test_invalid_candidate_ship_type_is_rejected(): + with pytest.raises(ValidationError, match='ship_type 不合法'): + FleetRuleRequest.model_validate( + { + 'name': '岛风', + 'candidates': [ + { + 'name': '雪风', + 'ship_type': ['invalid'], + }, + ], + }, + ) diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index f62315a2..cc8e4c0c 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -11,6 +11,7 @@ from autowsgr.context import GameContext from autowsgr.emulator import AndroidController from autowsgr.infra import DecisiveConfig +from autowsgr.server.schemas import FleetRuleRequest from autowsgr.ui.battle.base import PAGE_SIGNATURE from autowsgr.ui.battle.constants import ( AUTO_SUPPLY_PROBE, @@ -489,7 +490,9 @@ def test_custom_name_search_accepts_standard_name_result(self): assert page.change_fleet(1, [{'candidates': ['契卡洛夫']}]) assert change_ship.call_args.args[:2] == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector']['candidates'] == ['契卡洛夫'] + assert change_ship.call_args.kwargs['selector']['options'] == [ + {'name': '契卡洛夫'}, + ] def test_existing_group_variant_is_reordered_without_reselection(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) @@ -614,24 +617,122 @@ class TestFleetSlotRules: def test_normalize_ship_name(self, raw: object, expected: str | None): assert BattlePreparationPage._normalize_ship_name(raw) == expected - def test_name_and_candidates_form_one_slot_rule(self): + def test_primary_and_candidates_keep_independent_rules(self): selector = BattlePreparationPage._extract_selector( { 'name': '密苏里', - 'candidates': ['衣阿华', '密苏里'], - 'ship_type': 'BB', + 'candidates': [ + { + 'name': '衣阿华', + 'ship_type': ['BC'], + 'min_level': 90, + 'max_level': 105, + }, + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 80, + 'max_level': 110, + }, + ], + 'ship_type': ['BB'], 'min_level': 100, 'max_level': 110, }, ) assert selector == { - 'candidates': ['密苏里', '衣阿华'], - 'ship_type': 'bb', - 'min_level': 100, - 'max_level': 110, + 'options': [ + { + 'name': '密苏里', + 'ship_type': ['bb'], + 'min_level': 100, + 'max_level': 110, + }, + { + 'name': '衣阿华', + 'ship_type': ['bc'], + 'min_level': 90, + 'max_level': 105, + 'relaxed_constraints': True, + }, + { + 'name': '密苏里', + 'ship_type': ['bb'], + 'min_level': 80, + 'max_level': 110, + 'relaxed_constraints': True, + }, + ], + } + + def test_candidate_only_rules_keep_order_and_relax_constraints(self): + rule = FleetRuleRequest.model_validate( + { + 'candidates': [ + { + 'name': '胡德', + 'ship_type': ['BC'], + 'min_level': 90, + }, + { + 'name': '扶桑', + 'ship_type': ['BB'], + 'max_level': 110, + }, + ], + }, + ) + + assert BattlePreparationPage._extract_selector(rule) == { + 'options': [ + { + 'name': '胡德', + 'ship_type': ['bc'], + 'min_level': 90, + 'relaxed_constraints': True, + }, + { + 'name': '扶桑', + 'ship_type': ['bb'], + 'max_level': 110, + 'relaxed_constraints': True, + }, + ], } + def test_candidate_only_slots_use_backtracking(self): + selectors = [ + BattlePreparationPage._extract_selector( + FleetRuleRequest.model_validate( + { + 'candidates': [ + {'name': '胡德'}, + {'name': '扶桑'}, + ], + }, + ), + ), + BattlePreparationPage._extract_selector( + FleetRuleRequest.model_validate( + {'candidates': [{'name': '胡德'}]}, + ), + ), + None, + None, + None, + None, + ] + names = [ + selector['options'][0]['name'] if selector is not None else None + for selector in selectors + ] + + assert BattlePreparationPage._assign_unique_targets( + names, + selectors, + ) == ['扶桑', '胡德', None, None, None, None] + def test_overlapping_priorities_use_backtracking(self): names = ['A', 'A', None, None, None, None] selectors: list[dict | None] = [ @@ -680,7 +781,9 @@ def test_occupied_name_is_removed_from_slot_candidates(self): assert selected == '雪风' assert selector is not None - assert selector['candidates'] == ['雪风'] + assert selector['options'] == [ + {'name': '雪风', 'relaxed_constraints': True}, + ] def test_replacing_same_slot_may_keep_current_name(self): selected, _selector = BattlePreparationPage._select_available_candidate( @@ -756,7 +859,9 @@ def test_slot_failure_does_not_borrow_another_slot_candidates(self): assert change_ship.call_count == 1 assert change_ship.call_args.args == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector']['candidates'] == ['契卡洛夫'] + assert change_ship.call_args.kwargs['selector']['options'] == [ + {'name': '契卡洛夫', 'min_level': 100}, + ] def test_local_fix_replaces_before_removing(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) diff --git a/testing/ui/test_choose_ship_page.py b/testing/ui/test_choose_ship_page.py index 800fcee7..c48d65ed 100644 --- a/testing/ui/test_choose_ship_page.py +++ b/testing/ui/test_choose_ship_page.py @@ -1,6 +1,10 @@ """测试选船页的舰名比较逻辑。""" +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + from autowsgr.ui.choose_ship_page import ChooseShipPage +from autowsgr.ui.utils.ship_list import LevelOCRRetryNeededError from autowsgr.vision.ocr import set_ship_name_match_confidence from autowsgr.vision.ocr_rules import set_user_ship_name_aliases @@ -34,3 +38,181 @@ def test_user_alias_is_used_for_search_and_matching(self): assert ChooseShipPage._normalize_search_keyword('契卡洛夫') == '契卡洛夫' assert ChooseShipPage._matches_ship_name('契卡洛夫', '85工程') assert ChooseShipPage._matches_ship_name('85工程', '契卡洛夫') + + +class TestIndependentShipRules: + def test_each_option_uses_its_own_constraints(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + selector = { + 'options': [ + { + 'name': 'U-47', + 'search_name': 'U47', + 'ship_type': ['ss', 'ssg'], + 'min_level': 100, + 'max_level': 110, + }, + { + 'name': 'U-96', + 'search_name': 'U96', + 'ship_type': ['ss'], + 'min_level': 90, + 'max_level': 105, + 'relaxed_constraints': True, + }, + ], + } + + with ( + patch.object(page, 'ensure_search_box'), + patch.object(page, 'ensure_dismiss_keyboard'), + patch.object(page, 'input_ship_name') as input_name, + patch.object( + page, + '_click_ship_in_list', + side_effect=[None, 'U-96'], + ) as click_ship, + patch.object(page, '_wait_leave_current_page'), + ): + assert page.change_single_ship('U-47', selector=selector) == 'U-96' + + assert input_name.call_args_list == [call('U47'), call('U96')] + assert click_ship.call_args_list == [ + call( + 'U-47', + ship_type=['ss', 'ssg'], + min_level=100, + max_level=110, + relaxed_constraints=False, + ), + call( + 'U-96', + ship_type=['ss'], + min_level=90, + max_level=105, + relaxed_constraints=True, + ), + ] + + def test_multiple_ship_types_are_supported(self): + assert ChooseShipPage._is_ship_type_in_rule('ss', ['ss', 'ssg']) + assert ChooseShipPage._is_ship_type_in_rule('ssg', ['ss_or_ssg']) + assert not ChooseShipPage._is_ship_type_in_rule('bb', ['ss', 'ssg']) + + def test_primary_rejects_failed_level_constraint(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + + with ( + patch('autowsgr.ui.choose_ship_page._OCR_MAX_ATTEMPTS', 1), + patch( + 'autowsgr.ui.choose_ship_page.locate_ship_rows', + return_value=[('U-47', 0.2, 0.3, 0.4)], + ), + patch( + 'autowsgr.ui.choose_ship_page.read_ship_levels', + return_value=[('U-47', 90, 0.4)], + ), + ): + matched = page._click_ship_in_list( + 'U-47', + min_level=100, + ) + + assert matched is None + ctx.ctrl.click.assert_not_called() + + def test_relaxed_candidate_accepts_failed_level_constraint(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + + with ( + patch( + 'autowsgr.ui.choose_ship_page.locate_ship_rows', + return_value=[('U-96', 0.2, 0.3, 0.4)], + ), + patch( + 'autowsgr.ui.choose_ship_page.read_ship_levels', + return_value=[('U-96', 90, 0.4)], + ), + patch('autowsgr.ui.choose_ship_page.time.sleep'), + ): + matched = page._click_ship_in_list( + 'U-96', + min_level=100, + relaxed_constraints=True, + ) + + assert matched == 'U-96' + ctx.ctrl.click.assert_called_once_with(0.2, 0.3) + + def test_relaxed_candidate_accepts_failed_ship_type_constraint(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + + with ( + patch( + 'autowsgr.ui.choose_ship_page.locate_ship_rows', + return_value=[('U-96', 0.2, 0.3)], + ), + patch.object( + page, + '_detect_ship_type_near_hit', + return_value='bb', + ) as detect_ship_type, + patch('autowsgr.ui.choose_ship_page.time.sleep'), + ): + matched = page._click_ship_in_list( + 'U-96', + ship_type=['ss'], + relaxed_constraints=True, + ) + + assert matched == 'U-96' + detect_ship_type.assert_called_once() + ctx.ctrl.click.assert_called_once_with(0.2, 0.3) + + def test_relaxed_candidate_accepts_level_ocr_error(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + + with ( + patch( + 'autowsgr.ui.choose_ship_page.locate_ship_rows', + return_value=[('U-96', 0.2, 0.3, 0.4)], + ), + patch( + 'autowsgr.ui.choose_ship_page.read_ship_levels', + side_effect=LevelOCRRetryNeededError, + ) as read_levels, + patch('autowsgr.ui.choose_ship_page.time.sleep'), + ): + matched = page._click_ship_in_list( + 'U-96', + min_level=100, + relaxed_constraints=True, + ) + + assert matched == 'U-96' + read_levels.assert_called_once() + ctx.ctrl.click.assert_called_once_with(0.2, 0.3) + + def test_relaxed_candidate_still_rejects_wrong_name(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + + with ( + patch('autowsgr.ui.choose_ship_page._OCR_MAX_ATTEMPTS', 1), + patch( + 'autowsgr.ui.choose_ship_page.locate_ship_rows', + return_value=[('U-47', 0.2, 0.3)], + ), + ): + matched = page._click_ship_in_list( + 'U-96', + relaxed_constraints=True, + ) + + assert matched is None + ctx.ctrl.click.assert_not_called() From 851c245d4e3bc38c2b8d8b4321f0a2a70dbfc88e Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:15:53 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E6=A3=80=E6=9F=A5=E4=B8=8E=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/combat/plan.py | 17 ++++------------- autowsgr/ops/normal_fight.py | 4 +--- autowsgr/ui/choose_ship_page.py | 5 +---- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index d1770c93..6ead5bac 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -308,8 +308,7 @@ def _parse_fleet_presets(cls, raw: Any) -> list[dict[str, Any]] | None: for raw_preset in raw: name = cls._trim_text(raw_preset.get('name', '')) ships = [ - cls._normalize_preset_slot(raw_slot) - for raw_slot in raw_preset.get('ships', []) + cls._normalize_preset_slot(raw_slot) for raw_slot in raw_preset.get('ships', []) ] presets.append({'name': name, 'ships': ships}) return presets @@ -327,10 +326,7 @@ def _normalize_preset_slot(cls, raw_slot: Any) -> Any: if not isinstance(raw_slot, dict): return raw_slot - result = { - key: cls._trim_text(value) - for key, value in raw_slot.items() - } + result = {key: cls._trim_text(value) for key, value in raw_slot.items()} ship_types = cls._normalize_ship_types(result.get('ship_type')) if ship_types is not None: result['ship_type'] = ship_types @@ -381,10 +377,7 @@ def _normalize_ship_rule(cls, raw_rule: Any) -> Any: if not isinstance(raw_rule, dict): return raw_rule - result = { - key: cls._trim_text(value) - for key, value in raw_rule.items() - } + result = {key: cls._trim_text(value) for key, value in raw_rule.items()} ship_types = cls._normalize_ship_types(result.get('ship_type')) if ship_types is not None: result['ship_type'] = ship_types @@ -398,9 +391,7 @@ def _normalize_ship_types(cls, raw: Any) -> list[str] | None: return None normalized = [ - value.strip().lower() - for value in values - if isinstance(value, str) and value.strip() + value.strip().lower() for value in values if isinstance(value, str) and value.strip() ] return list(dict.fromkeys(normalized)) or None diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index b7783d87..f2e7892b 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -53,9 +53,7 @@ def __init__( self._fleet_id = fleet_id if fleet_id is not None else plan.fleet_id self._fleet = fleet if fleet is not None else plan.fleet self._fleet_rules = ( - fleet_rules - if fleet_rules is not None - else self._fleet_rules_from_plan(plan) + fleet_rules if fleet_rules is not None else self._fleet_rules_from_plan(plan) ) # 从 config 读取拆船配置 diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index f9702925..68e25fd5 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -574,10 +574,7 @@ def _is_ship_type_in_rule( return False rules = [expected] if isinstance(expected, str) else expected normalized = {rule.strip().lower() for rule in rules} - return detected in normalized or ( - 'ss_or_ssg' in normalized - and detected in {'ss', 'ssg'} - ) + return detected in normalized or ('ss_or_ssg' in normalized and detected in {'ss', 'ssg'}) @staticmethod def _normalize_search_keyword(name: str) -> str: From 100906a36ce37642ded688dada9efda6f0920495 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:09:06 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E8=88=B0=E9=98=9F=E9=80=89=E6=8B=A9=E4=B8=8E=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8E=A5=E5=8F=A3=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autowsgr/combat/__init__.py | 18 + autowsgr/combat/fleet.py | 355 ++++++++++++++++++++ autowsgr/combat/plan.py | 104 +----- autowsgr/ops/event_fight.py | 36 +- autowsgr/ops/normal_fight.py | 111 ++---- autowsgr/scheduler/daily_plan.py | 7 +- autowsgr/server/routes/task.py | 37 +- autowsgr/server/schemas.py | 88 ++--- autowsgr/server/serializers.py | 43 ++- autowsgr/ui/battle/fleet_change.py | 10 +- autowsgr/ui/battle/fleet_change/_change.py | 254 +++----------- autowsgr/ui/battle/fleet_change/_detect.py | 13 + autowsgr/ui/choose_ship_page.py | 172 ++-------- autowsgr/ui/decisive/legacy_fleet_change.py | 17 +- autowsgr/ui/decisive/map_controller.py | 3 +- autowsgr/ui/decisive/preparation.py | 4 +- examples/change_fleet.py | 3 +- examples/week.py | 3 +- testing/combat/test_combat.py | 168 ++++----- testing/ops/event_fight.py | 2 + testing/ops/normal_fight.py | 3 +- testing/ops/scheduler.py | 8 +- testing/ops/test_normal_fight_unit.py | 103 ++++-- testing/test_server_schemas.py | 177 ++++++++-- testing/ui/battle_preparation/test_unit.py | 346 +++++++++++++------ testing/ui/test_choose_ship_page.py | 72 ++-- 26 files changed, 1252 insertions(+), 905 deletions(-) create mode 100644 autowsgr/combat/fleet.py diff --git a/autowsgr/combat/__init__.py b/autowsgr/combat/__init__.py index 8eb345f5..4eed78be 100644 --- a/autowsgr/combat/__init__.py +++ b/autowsgr/combat/__init__.py @@ -1,6 +1,16 @@ """战斗系统 — 独立于 UI 框架的战斗状态机引擎。""" from .engine import CombatEngine, run_combat +from .fleet import ( + ALLOWED_SHIP_TYPE_CODES, + FleetPreset, + FleetSelectionSource, + FleetSlotRule, + ResolvedFleetSelection, + ShipSelector, + fleet_slot_from_api, + resolve_fleet_selection, +) from .history import CombatEvent, CombatHistory, CombatResult, FightResult from .node_tracker import MapNodeData, NodeTracker from .plan import CombatMode, CombatPlan, NodeDecision @@ -15,6 +25,7 @@ __all__ = [ + 'ALLOWED_SHIP_TYPE_CODES', 'SHIP_DROP_PAGE_SIGNATURE', 'CombatEngine', 'CombatEvent', @@ -24,13 +35,20 @@ 'CombatPlan', 'CombatResult', 'FightResult', + 'FleetPreset', + 'FleetSelectionSource', + 'FleetSlotRule', 'MapNodeData', 'NodeDecision', 'NodeTracker', + 'ResolvedFleetSelection', 'RuleEngine', 'RuleResult', 'ShipDropResult', + 'ShipSelector', + 'fleet_slot_from_api', 'recognize_enemy_formation', 'recognize_ship_drop', + 'resolve_fleet_selection', 'run_combat', ] diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py new file mode 100644 index 00000000..b177fb09 --- /dev/null +++ b/autowsgr/combat/fleet.py @@ -0,0 +1,355 @@ +"""舰队规则领域模型和入口转换。 + +YAML 与 HTTP 请求只在各自入口转换一次。执行器和 UI 只接收本模块定义的 +不可变对象,不再解释字典、Pydantic DTO 或旧 candidates 字符串格式。 +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +from autowsgr.types import ShipType + + +if TYPE_CHECKING: + from autowsgr.combat.plan import CombatPlan + + +SHIP_TYPE_BY_CODE = MappingProxyType( + { + 'dd': (ShipType.DD,), + 'cl': (ShipType.CL,), + 'ca': (ShipType.CA,), + 'cav': (ShipType.CAV,), + 'clt': (ShipType.CLT,), + 'bb': (ShipType.BB,), + 'bc': (ShipType.BC,), + 'bbv': (ShipType.BBV,), + 'cv': (ShipType.CV,), + 'cvl': (ShipType.CVL,), + 'av': (ShipType.AV,), + 'ss': (ShipType.SS,), + 'ssg': (ShipType.SSG,), + 'cg': (ShipType.KP,), + 'cgaa': (ShipType.CG,), + 'ddg': (ShipType.ASDG,), + 'ddgaa': (ShipType.AADG,), + 'bm': (ShipType.BM,), + 'cbg': (ShipType.CBG,), + 'ss_or_ssg': (ShipType.SS, ShipType.SSG), + 'ap': (ShipType.NAP,), + 'bbg': (ShipType.BG,), + 'sc': (ShipType.SC,), + }, +) +"""API 舰种缩写到后端领域枚举的唯一映射。""" + +ALLOWED_SHIP_TYPE_CODES = frozenset(SHIP_TYPE_BY_CODE) + + +def parse_ship_type_codes(raw: object) -> tuple[ShipType, ...]: + """校验舰种缩写并转换为去重后的领域枚举。""" + if raw is None or raw == '': + return () + values = [raw] if isinstance(raw, str) else raw + if not isinstance(values, Sequence): + raise TypeError('ship_type 必须是非空字符串列表') + + result: list[ShipType] = [] + for value in values: + if not isinstance(value, str) or not value.strip(): + raise ValueError('ship_type 必须是非空字符串列表') + code = value.strip().lower() + ship_types = SHIP_TYPE_BY_CODE.get(code) + if ship_types is None: + allowed = ', '.join(sorted(ALLOWED_SHIP_TYPE_CODES)) + raise ValueError(f'ship_type 不合法: {value!r}, 可选值: {allowed}') + for ship_type in ship_types: + if ship_type not in result: + result.append(ship_type) + return tuple(result) + + +@dataclass(frozen=True, slots=True) +class ShipSelector: + """一艘主选或备选舰船的完整选择规则。""" + + name: str + search_name: str | None = None + ship_types: tuple[ShipType, ...] = () + min_level: int | None = None + max_level: int | None = None + relaxed_constraints: bool = False + + def __post_init__(self) -> None: + name = self.name.strip() + if not name: + raise ValueError('name 不能为空') + object.__setattr__(self, 'name', name) + + search_name = self.search_name.strip() if self.search_name else None + object.__setattr__(self, 'search_name', search_name) + if self.min_level is not None and self.min_level < 1: + raise ValueError('min_level 必须大于等于 1') + if self.max_level is not None and self.max_level < 1: + raise ValueError('max_level 必须大于等于 1') + if ( + self.min_level is not None + and self.max_level is not None + and self.max_level < self.min_level + ): + raise ValueError('max_level 必须大于或等于 min_level') + + +@dataclass(frozen=True, slots=True) +class FleetSlotRule: + """一个舰队槽位的严格主选和有序宽泛备选。""" + + primary: ShipSelector | None = None + candidates: tuple[ShipSelector, ...] = () + + def __post_init__(self) -> None: + if self.primary is None and not self.candidates: + raise ValueError('位置至少需要一艘主选或备选舰船') + + @property + def options(self) -> tuple[ShipSelector, ...]: + """返回智能编队按顺序尝试的完整规则。""" + if self.primary is None: + return self.candidates + return (self.primary, *self.candidates) + + @property + def preferred_name(self) -> str: + """返回集合分配开始时使用的首个舰名。""" + return self.options[0].name + + +@dataclass(frozen=True, slots=True) +class FleetPreset: + """YAML 中一套已经完成入口转换的舰队预设。""" + + name: str + slots: tuple[FleetSlotRule, ...] + + +class FleetSelectionSource(StrEnum): + """最终舰队选择的数据来源。""" + + OVERRIDE_RULES = 'override_rules' + OVERRIDE_FLEET = 'override_fleet' + PLAN_PRESET = 'plan_preset' + PLAN_FLEET = 'plan_fleet' + NONE = 'none' + + +@dataclass(frozen=True, slots=True) +class ResolvedFleetSelection: + """runner 启动前确定的唯一舰队选择结果。""" + + fleet_id: int + slot_rules: tuple[FleetSlotRule, ...] | None + plain_fleet: tuple[str, ...] | None + source: FleetSelectionSource + + @property + def primary_names(self) -> list[str | None] | None: + """返回战斗记录可使用的显式主选舰名。""" + if self.slot_rules is not None: + return [ + rule.primary.name if rule.primary is not None else None + for rule in self.slot_rules[:6] + ] + return list(self.plain_fleet) if self.plain_fleet is not None else None + + +def _optional_text(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError('舰船名称必须是字符串') + return value.strip() or None + + +def _optional_level(rule: Mapping[str, Any], field: str) -> int | None: + value = rule.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f'{field} 必须是整数') + return value + + +def _selector_from_mapping( + raw: Mapping[str, Any], + *, + relaxed: bool, + inherited: Mapping[str, Any] | None = None, +) -> ShipSelector: + name = _optional_text(raw.get('name')) + if name is None: + raise ValueError('name 不能为空') + source = raw if inherited is None else inherited + return ShipSelector( + name=name, + search_name=_optional_text(raw.get('search_name')), + ship_types=parse_ship_type_codes(source.get('ship_type')), + min_level=_optional_level(source, 'min_level'), + max_level=_optional_level(source, 'max_level'), + relaxed_constraints=relaxed, + ) + + +def fleet_slot_from_api(raw: str | Mapping[str, Any]) -> FleetSlotRule: + """把已经通过 HTTP schema 的槽位转换成 canonical 规则。""" + if isinstance(raw, str): + return FleetSlotRule(primary=ShipSelector(name=raw)) + if not isinstance(raw, Mapping): + raise TypeError('舰队槽位必须是字符串或规则对象') + + name = _optional_text(raw.get('name')) + primary = _selector_from_mapping(raw, relaxed=False) if name is not None else None + raw_candidates = raw.get('candidates', []) + if not isinstance(raw_candidates, Sequence) or isinstance(raw_candidates, str): + raise TypeError('candidates 必须是规则对象列表') + candidates = tuple( + _selector_from_mapping(candidate, relaxed=True) + for candidate in raw_candidates + if isinstance(candidate, Mapping) + ) + if len(candidates) != len(raw_candidates): + raise TypeError('HTTP candidates 只接受规则对象') + return FleetSlotRule(primary=primary, candidates=candidates) + + +def fleet_slot_from_yaml(raw: object) -> FleetSlotRule: + """把 YAML 槽位转换成 canonical 规则,并仅在此兼容旧字符串候选。""" + if isinstance(raw, str): + return FleetSlotRule(primary=ShipSelector(name=raw)) + if not isinstance(raw, Mapping): + raise TypeError('舰队槽位必须是字符串或规则对象') + + raw_candidates = raw.get('candidates', []) + if not isinstance(raw_candidates, Sequence) or isinstance(raw_candidates, str): + raise TypeError('candidates 必须是列表') + candidates = list(raw_candidates) + + primary: ShipSelector | None = None + if _optional_text(raw.get('name')) is not None: + primary = _selector_from_mapping(raw, relaxed=False) + else: + primary_index = next( + ( + index + for index, candidate in enumerate(candidates) + if isinstance(candidate, str) and candidate.strip() + ), + None, + ) + if primary_index is not None: + primary_name = candidates.pop(primary_index) + primary = _selector_from_mapping( + { + 'name': primary_name, + 'search_name': raw.get('search_name'), + }, + relaxed=False, + inherited=raw, + ) + + normalized_candidates: list[ShipSelector] = [] + seen: set[str] = set() + for candidate in candidates: + if isinstance(candidate, str): + selector = _selector_from_mapping( + {'name': candidate}, + relaxed=True, + inherited=raw, + ) + elif isinstance(candidate, Mapping): + selector = _selector_from_mapping(candidate, relaxed=True) + else: + raise TypeError('candidates 只能包含舰名字符串或规则对象') + if selector.name in seen: + continue + normalized_candidates.append(selector) + seen.add(selector.name) + return FleetSlotRule(primary=primary, candidates=tuple(normalized_candidates)) + + +def fleet_presets_from_yaml(raw: object) -> tuple[FleetPreset, ...] | None: + """解析 YAML 的舰队预设列表。""" + if raw is None: + return None + if not isinstance(raw, list): + raise TypeError('fleet_presets 必须是列表') + + presets: list[FleetPreset] = [] + for raw_preset in raw: + if not isinstance(raw_preset, Mapping): + raise TypeError('fleet_presets 每一项必须是对象') + name = _optional_text(raw_preset.get('name')) or '' + raw_slots = raw_preset.get('ships', []) + if not isinstance(raw_slots, list): + raise TypeError('fleet_presets.ships 必须是列表') + presets.append( + FleetPreset( + name=name, + slots=tuple(fleet_slot_from_yaml(slot) for slot in raw_slots), + ), + ) + return tuple(presets) + + +def exact_fleet_rules(names: Sequence[str]) -> tuple[FleetSlotRule, ...]: + """把普通舰名列表转换成精确槽位规则。""" + return tuple(FleetSlotRule(primary=ShipSelector(name=name)) for name in names) + + +def resolve_fleet_selection( + plan: CombatPlan, + *, + fleet_id: int | None = None, + fleet: Sequence[str] | None = None, + slot_rules: Sequence[FleetSlotRule] | None = None, +) -> ResolvedFleetSelection: + """按 override rules > override fleet > plan preset > plan fleet 集中解析。""" + resolved_id = fleet_id if fleet_id is not None else plan.fleet_id + if slot_rules is not None: + return ResolvedFleetSelection( + fleet_id=resolved_id, + slot_rules=tuple(slot_rules), + plain_fleet=None, + source=FleetSelectionSource.OVERRIDE_RULES, + ) + if fleet is not None: + return ResolvedFleetSelection( + fleet_id=resolved_id, + slot_rules=None, + plain_fleet=tuple(fleet), + source=FleetSelectionSource.OVERRIDE_FLEET, + ) + if plan.fleet_presets: + return ResolvedFleetSelection( + fleet_id=resolved_id, + slot_rules=plan.fleet_presets[0].slots, + plain_fleet=None, + source=FleetSelectionSource.PLAN_PRESET, + ) + if plan.fleet is not None: + return ResolvedFleetSelection( + fleet_id=resolved_id, + slot_rules=None, + plain_fleet=tuple(plan.fleet), + source=FleetSelectionSource.PLAN_FLEET, + ) + return ResolvedFleetSelection( + fleet_id=resolved_id, + slot_rules=None, + plain_fleet=None, + source=FleetSelectionSource.NONE, + ) diff --git a/autowsgr/combat/plan.py b/autowsgr/combat/plan.py index 6ead5bac..7cd07015 100644 --- a/autowsgr/combat/plan.py +++ b/autowsgr/combat/plan.py @@ -19,6 +19,7 @@ from autowsgr.infra.logger import get_logger from autowsgr.types import FightCondition, Formation, RepairMode +from .fleet import FleetPreset, fleet_presets_from_yaml from .rules import RuleEngine from .state import ( CombatPhase, @@ -261,7 +262,7 @@ class CombatPlan: """ fleet_id: int = 1 fleet: list[str] | None = None - fleet_presets: list[dict[str, Any]] | None = None + fleet_presets: tuple[FleetPreset, ...] | None = None repair_mode: RepairMode | list[RepairMode] = RepairMode.severe_damage fight_condition: FightCondition = FightCondition.aim selected_nodes: list[str] = field(default_factory=list) @@ -296,105 +297,6 @@ def is_selected_node(self, node: str) -> bool: return True # 未配置白名单 = 全部允许 return node in self.selected_nodes - @classmethod - def _parse_fleet_presets(cls, raw: Any) -> list[dict[str, Any]] | None: - """解析舰队预设,并整理名称、舰名和候选列表。""" - if raw is None: - return None - if not isinstance(raw, list): - raise TypeError('fleet_presets 必须是列表') - - presets: list[dict[str, Any]] = [] - for raw_preset in raw: - name = cls._trim_text(raw_preset.get('name', '')) - ships = [ - cls._normalize_preset_slot(raw_slot) for raw_slot in raw_preset.get('ships', []) - ] - presets.append({'name': name, 'ships': ships}) - return presets - - @staticmethod - def _trim_text(value: Any) -> Any: - """删除字符串首尾空格,其他类型保持不变。""" - return value.strip() if isinstance(value, str) else value - - @classmethod - def _normalize_preset_slot(cls, raw_slot: Any) -> Any: - """整理主选和位置级备选,并兼容旧字符串候选。""" - if isinstance(raw_slot, str): - return raw_slot.strip() - if not isinstance(raw_slot, dict): - return raw_slot - - result = {key: cls._trim_text(value) for key, value in raw_slot.items()} - ship_types = cls._normalize_ship_types(result.get('ship_type')) - if ship_types is not None: - result['ship_type'] = ship_types - - raw_candidates = result.get('candidates') - if not isinstance(raw_candidates, list): - return result - - candidates = list(raw_candidates) - if not isinstance(result.get('name'), str) or not result['name']: - primary_index = next( - ( - index - for index, candidate in enumerate(candidates) - if isinstance(candidate, str) and candidate.strip() - ), - None, - ) - if primary_index is not None: - result['name'] = candidates.pop(primary_index).strip() - - shared = { - key: result[key] - for key in ('ship_type', 'min_level', 'max_level') - if result.get(key) is not None - } - normalized_candidates: list[dict[str, Any]] = [] - # 同名主选和备选分别承担严格、宽泛规则,只去除备选队列内部的重复项。 - seen: set[str] = set() - for candidate in candidates: - if isinstance(candidate, str): - rule = {'name': candidate.strip(), **copy.deepcopy(shared)} - else: - rule = cls._normalize_ship_rule(candidate) - if not isinstance(rule, dict): - continue - name = rule.get('name') - if not isinstance(name, str) or not name or name in seen: - continue - normalized_candidates.append(rule) - seen.add(name) - result['candidates'] = normalized_candidates - return result - - @classmethod - def _normalize_ship_rule(cls, raw_rule: Any) -> Any: - """整理一艘备选舰船自己的规则。""" - if not isinstance(raw_rule, dict): - return raw_rule - - result = {key: cls._trim_text(value) for key, value in raw_rule.items()} - ship_types = cls._normalize_ship_types(result.get('ship_type')) - if ship_types is not None: - result['ship_type'] = ship_types - return result - - @classmethod - def _normalize_ship_types(cls, raw: Any) -> list[str] | None: - """把旧单舰种字符串和新舰种列表统一为小写字符串列表。""" - values = [raw] if isinstance(raw, str) else raw - if not isinstance(values, list): - return None - - normalized = [ - value.strip().lower() for value in values if isinstance(value, str) and value.strip() - ] - return list(dict.fromkeys(normalized)) or None - @classmethod def from_yaml(cls, path: str | Path) -> CombatPlan: from autowsgr.infra.config_compat import ( @@ -422,7 +324,7 @@ def from_dict(cls, data: dict[str, Any], name: str = '') -> CombatPlan: map_id, entrance = parse_map_value(data.get('map', 1)) fleet_id = data.get('fleet_id', 1) fleet = data.get('fleet') - fleet_presets = cls._parse_fleet_presets(data.get('fleet_presets')) + fleet_presets = fleet_presets_from_yaml(data.get('fleet_presets')) fight_condition = FightCondition(data.get('fight_condition', 4)) selected_nodes = data.get('selected_nodes', []) diff --git a/autowsgr/ops/event_fight.py b/autowsgr/ops/event_fight.py index 1041d235..c7beb018 100644 --- a/autowsgr/ops/event_fight.py +++ b/autowsgr/ops/event_fight.py @@ -20,14 +20,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Literal from autowsgr.combat import CombatPlan, CombatResult +from autowsgr.combat.fleet import ( + FleetSlotRule, + ResolvedFleetSelection, + resolve_fleet_selection, +) from autowsgr.infra.logger import get_logger from autowsgr.ops.normal_fight import NormalFightRunner if TYPE_CHECKING: + from collections.abc import Sequence + from autowsgr.context import GameContext _log = get_logger('ops') @@ -63,13 +70,11 @@ def __init__( self, ctx: GameContext, plan: CombatPlan, + fleet_selection: ResolvedFleetSelection, *, map_code: str | None = None, # noqa: ARG002 - 已废弃, 仅为兼容旧签名保留 entrance: Literal['alpha', 'beta'] | None = None, event_name: str | None = None, - fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, ) -> None: # entrance override: 覆盖 plan.entrance (UI 层 a/b ↔ α/β) if entrance is not None: @@ -80,9 +85,7 @@ def __init__( super().__init__( ctx, plan, - fleet_id=fleet_id, - fleet=fleet, - fleet_rules=fleet_rules, + fleet_selection, ) @@ -100,8 +103,9 @@ def run_event_fight( times: int = 1, gap: float = 0.0, fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, + fleet: Sequence[str] | None = None, + fleet_rules: Sequence[FleetSlotRule] | None = None, + fleet_selection: ResolvedFleetSelection | None = None, ) -> list[CombatResult]: """执行活动战的便捷函数 (兼容入口, 委托 :class:`NormalFightRunner`)。 @@ -127,14 +131,18 @@ def run_event_fight( ------- list[CombatResult] """ + resolved_selection = fleet_selection or resolve_fleet_selection( + plan, + fleet_id=fleet_id, + fleet=fleet, + slot_rules=fleet_rules, + ) runner = EventFightRunner( ctx, plan, + resolved_selection, map_code=map_code, entrance=entrance, - fleet_id=fleet_id, - fleet=fleet, - fleet_rules=fleet_rules, ) return runner.run_for_times(times, gap=gap) @@ -147,8 +155,8 @@ def run_event_fight_from_yaml( entrance: Literal['alpha', 'beta'] | None = None, times: int = 1, fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, + fleet: Sequence[str] | None = None, + fleet_rules: Sequence[FleetSlotRule] | None = None, ) -> list[CombatResult]: """从 YAML 文件加载计划并执行活动战 (兼容入口)。 diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index f2e7892b..6dea7a64 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -8,10 +8,16 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Literal from autowsgr.combat import CombatMode, CombatPlan, CombatResult from autowsgr.combat.engine import run_combat +from autowsgr.combat.fleet import ( + FleetSlotRule, + ResolvedFleetSelection, + exact_fleet_rules, + resolve_fleet_selection, +) from autowsgr.infra import ActionFailedError from autowsgr.infra.logger import get_logger from autowsgr.ops.navigate import goto_page @@ -21,6 +27,7 @@ if TYPE_CHECKING: + from collections.abc import Sequence from pathlib import Path from autowsgr.context import GameContext @@ -43,18 +50,13 @@ def __init__( self, ctx: GameContext, plan: CombatPlan, - fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, + fleet_selection: ResolvedFleetSelection, ) -> None: self._ctx = ctx self._ctrl = ctx.ctrl self._plan = plan - self._fleet_id = fleet_id if fleet_id is not None else plan.fleet_id - self._fleet = fleet if fleet is not None else plan.fleet - self._fleet_rules = ( - fleet_rules if fleet_rules is not None else self._fleet_rules_from_plan(plan) - ) + self._fleet_selection = fleet_selection + self._fleet_id = fleet_selection.fleet_id # 从 config 读取拆船配置 self._dock_full_destroy = ctx.config.dock_full_destroy @@ -93,53 +95,6 @@ def __init__( self._ship_acquired_count: int | None = None self._fleet_ships: list[Ship] | None = None - @staticmethod - def _fleet_rules_from_plan(plan: CombatPlan) -> list[Any] | None: - """未传接口覆盖值时,使用计划中第一套舰队预设。""" - if not plan.fleet_presets: - return None - ships = plan.fleet_presets[0].get('ships') - return ships if isinstance(ships, list) else None - - @staticmethod - def _primary_names_from_rules(fleet_rules: list[Any] | None) -> list[str | None] | None: - """读取每个槽位显式声明的主选舰名。""" - if not fleet_rules: - return None - - def _normalize_name(value: object) -> str | None: - if value is None: - return None - name = str(value).strip() - return name or None - - names: list[str | None] = [] - for slot in fleet_rules[:6]: - if isinstance(slot, str): - names.append(_normalize_name(slot)) - continue - - if isinstance(slot, dict): - name = slot.get('name') - candidates = slot.get('candidates') - else: - name = getattr(slot, 'name', None) - candidates = getattr(slot, 'candidates', None) - - normalized_name = _normalize_name(name) - if normalized_name is not None: - names.append(normalized_name) - continue - - # 旧规则没有 name,candidates 的第一个字符串才是主选。 - if isinstance(candidates, list) and len(candidates) > 0: - legacy_name = candidates[0] - if isinstance(legacy_name, str): - names.append(_normalize_name(legacy_name)) - continue - names.append(None) - return names - # ── 公共接口 ── def run(self) -> CombatResult: @@ -155,12 +110,11 @@ def run(self) -> CombatResult: CombatResult """ _log.info( - '[OPS] 常规战: {}-{} ({})', + '[OPS] 常规战: {}-{} ({}), 舰队来源: {}', self._plan.chapter, self._plan.map_id, self._plan.name, - self._fleet_id, - self._fleet, + self._fleet_selection.source, ) # 1. 进入战斗地图 @@ -392,17 +346,19 @@ def _prepare_for_battle(self) -> list[ShipDamageState]: resolved_ship_names: list[str | None] | None = None - # 换船 (若提供了规则则优先按规则执行) - if self._fleet_rules is not None: + # 换船规则已经在 runner 启动前完成优先级解析和入口转换。 + slot_rules = self._fleet_selection.slot_rules + plain_fleet = self._fleet_selection.plain_fleet + if slot_rules is not None: _require_fleet_change( - page.change_fleet(self._fleet_id, self._fleet_rules), - '外部 fleet_rules', + page.change_fleet(self._fleet_id, slot_rules), + 'fleet_rules', ) time.sleep(0.5) resolved_ship_names = page.detect_fleet() - elif self._fleet is not None: + elif plain_fleet is not None: _require_fleet_change( - page.change_fleet(self._fleet_id, self._fleet), + page.change_fleet(self._fleet_id, exact_fleet_rules(plain_fleet)), 'fleet', ) time.sleep(0.5) @@ -431,11 +387,7 @@ def _prepare_for_battle(self) -> list[ShipDamageState]: raise ActionFailedError('出征前检测到大破舰船,退出程序') ship_names = resolved_ship_names if ship_names is None: - ship_names = ( - self._primary_names_from_rules(self._fleet_rules) - if self._fleet_rules is not None - else self._fleet - ) + ship_names = self._fleet_selection.primary_names self._fleet_ships = fleet_info.to_ships(ship_names) # 出征 @@ -528,16 +480,21 @@ def run_normal_fight( times: int = 1, gap: float = 0.0, fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, + fleet: Sequence[str] | None = None, + fleet_rules: Sequence[FleetSlotRule] | None = None, + fleet_selection: ResolvedFleetSelection | None = None, ) -> list[CombatResult]: """执行常规战的便捷函数。""" - runner = NormalFightRunner( - ctx, + resolved_selection = fleet_selection or resolve_fleet_selection( plan, fleet_id=fleet_id, fleet=fleet, - fleet_rules=fleet_rules, + slot_rules=fleet_rules, + ) + runner = NormalFightRunner( + ctx, + plan, + resolved_selection, ) return runner.run_for_times(times, gap=gap) @@ -548,8 +505,8 @@ def run_normal_fight_from_yaml( *, times: int = 1, fleet_id: int | None = None, - fleet: list[str] | None = None, - fleet_rules: list[Any] | None = None, + fleet: Sequence[str] | None = None, + fleet_rules: Sequence[FleetSlotRule] | None = None, plan_root: str | Path | None = None, ) -> list[CombatResult]: """从 YAML 文件加载计划并执行常规战。 diff --git a/autowsgr/scheduler/daily_plan.py b/autowsgr/scheduler/daily_plan.py index 6d0af417..bba87c4d 100644 --- a/autowsgr/scheduler/daily_plan.py +++ b/autowsgr/scheduler/daily_plan.py @@ -228,6 +228,7 @@ def _register_normal_fight( *plan_root* 透传给 :func:`get_normal_fight_plan`, 用户自定义目录优先。 """ + from autowsgr.combat.fleet import resolve_fleet_selection from autowsgr.ops.normal_fight import NormalFightRunner, get_normal_fight_plan plans: list[NormalFightPlan] = [] @@ -245,7 +246,11 @@ def _register_normal_fight( plans.append( NormalFightPlan( # 默认参数捕获 plan/fleet, 避免闭包晚绑定 - factory=lambda c, p=plan, f=fleet_id: NormalFightRunner(c, p, fleet_id=f), + factory=lambda c, p=plan, f=fleet_id: NormalFightRunner( + c, + p, + resolve_fleet_selection(p, fleet_id=f), + ), name=task.name, fleet_id=fleet_id, target=task.times, # None = 无限 (空闲填充) diff --git a/autowsgr/server/routes/task.py b/autowsgr/server/routes/task.py index e4d3fbeb..55c5aa4d 100644 --- a/autowsgr/server/routes/task.py +++ b/autowsgr/server/routes/task.py @@ -18,7 +18,11 @@ NormalFightRequest, TaskStatusResponse, ) -from autowsgr.server.serializers import build_combat_plan, convert_combat_result +from autowsgr.server.serializers import ( + build_combat_plan, + build_fleet_selection, + convert_combat_result, +) from autowsgr.server.task_manager import TaskOutcome, task_manager from ..main import get_context, lifecycle_lock @@ -117,11 +121,8 @@ def executor(_task_info: Any) -> TaskOutcome: else: raise ValueError('必须提供 plan 或 plan_id') - # 允许 plan_id + plan 覆盖: 前端可在不改 YAML 的情况下动态指定舰队与舰船名单。 - request_plan = request.plan - override_fleet_id = request_plan.fleet_id if request_plan is not None else None - override_fleet = request_plan.fleet if request_plan is not None else None - override_fleet_rules = request_plan.fleet_rules if request_plan is not None else None + # API plan 覆盖 YAML 舰队;DTO 在 runner 启动前转换成领域模型。 + fleet_selection = build_fleet_selection(plan, request.plan) for i in range(request.times): if task_manager.should_stop(): @@ -135,9 +136,7 @@ def executor(_task_info: Any) -> TaskOutcome: ctx, plan, times=1, - fleet_id=override_fleet_id, - fleet=override_fleet, - fleet_rules=override_fleet_rules, + fleet_selection=fleet_selection, )[0] results.append(convert_combat_result(result, i + 1)) task_manager.add_result(results[-1]) @@ -175,16 +174,12 @@ def executor(_task_info: Any) -> TaskOutcome: else: raise ValueError('必须提供 plan 或 plan_id') - request_plan = request.plan - override_fleet = request_plan.fleet if request_plan is not None else None - override_fleet_rules = request_plan.fleet_rules if request_plan is not None else None - # 优先级: 顶层 fleet_id > plan 覆盖 fleet_id > YAML 内 fleet_id - if request.fleet_id is not None: - fleet_id = request.fleet_id - elif request_plan is not None and request_plan.fleet_id is not None: - fleet_id = request_plan.fleet_id - else: - fleet_id = plan.fleet_id + # 活动战顶层 fleet_id 优先,其余覆盖规则与普通战完全一致。 + fleet_selection = build_fleet_selection( + plan, + request.plan, + fleet_id=request.fleet_id, + ) for i in range(request.times): if task_manager.should_stop(): @@ -198,9 +193,7 @@ def executor(_task_info: Any) -> TaskOutcome: ctx, plan, times=1, - fleet_id=fleet_id, - fleet=override_fleet, - fleet_rules=override_fleet_rules, + fleet_selection=fleet_selection, )[0] results.append(convert_combat_result(result, i + 1)) task_manager.add_result(results[-1]) diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index 5c55c502..bb857baa 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -7,30 +7,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator - -_ALLOWED_SHIP_TYPE_CODES = { - 'dd', - 'cl', - 'ca', - 'cav', - 'clt', - 'bb', - 'bc', - 'bbv', - 'cv', - 'cvl', - 'av', - 'ss', - 'ssg', - 'cg', - 'cgaa', - 'ddg', - 'ddgaa', - 'bm', - 'cbg', - 'cf', - 'ss_or_ssg', -} +from autowsgr.combat.fleet import ALLOWED_SHIP_TYPE_CODES # ═══════════════════════════════════════════════════════════════════════════════ @@ -91,10 +68,28 @@ class NodeDecisionRequest(BaseModel): default=True, description='迂回失败时是否 SL', ) - enemy_rules: list[list[str]] | None = Field( + enemy_rules: list[str | list] | None = Field( default=None, description='索敌规则', ) + enemy_formation_rules: list[str | list] | None = Field( + default=None, + description='敌方阵型规则', + ) + SL_when_spot_enemy_fails: bool = Field( + default=False, + description='索敌失败时是否 SL', + ) + SL_when_enter_fight: bool = Field( + default=False, + description='进入战斗时是否 SL', + ) + formation_when_spot_enemy_fails: int | None = Field( + default=None, + ge=1, + le=5, + description='索敌失败时使用的替代阵型', + ) model_config = {'extra': 'forbid'} @@ -138,8 +133,8 @@ def _validate_ship_type(cls, value: Any) -> list[str] | None: if not isinstance(ship_type, str) or not ship_type.strip(): raise ValueError('ship_type 必须是非空字符串列表') code = ship_type.strip().lower() - if code not in _ALLOWED_SHIP_TYPE_CODES: - allowed = ', '.join(sorted(_ALLOWED_SHIP_TYPE_CODES)) + if code not in ALLOWED_SHIP_TYPE_CODES: + allowed = ', '.join(sorted(ALLOWED_SHIP_TYPE_CODES)) raise ValueError(f'ship_type 不合法: {ship_type!r}, 可选值: {allowed}') if code not in normalized: normalized.append(code) @@ -174,45 +169,6 @@ def _validate_name(cls, value: str | None) -> str | None: return None return value.strip() or None - @model_validator(mode='before') - @classmethod - def _upgrade_legacy_candidates(cls, value: Any) -> Any: - """兼容旧 candidates 字符串列表,主选迁移到 name。""" - if not isinstance(value, dict): - return value - - result = dict(value) - raw_candidates = result.get('candidates') - if not isinstance(raw_candidates, list): - return result - - candidates = list(raw_candidates) - if not isinstance(result.get('name'), str) or not result['name'].strip(): - first_name = next( - ( - candidate - for candidate in candidates - if isinstance(candidate, str) and candidate.strip() - ), - None, - ) - if first_name is None: - return result - result['name'] = first_name - candidates.remove(first_name) - - shared = { - key: result[key] - for key in ('ship_type', 'min_level', 'max_level') - if result.get(key) is not None - } - result['candidates'] = [ - {'name': candidate, **shared} if isinstance(candidate, str) else candidate - for candidate in candidates - if not isinstance(candidate, str) or candidate.strip() - ] - return result - @model_validator(mode='after') def _validate_slot(self) -> FleetRuleRequest: """无主选时只允许保留非空的位置级备选队列。""" diff --git a/autowsgr/server/serializers.py b/autowsgr/server/serializers.py index 411d559b..cc922c76 100644 --- a/autowsgr/server/serializers.py +++ b/autowsgr/server/serializers.py @@ -6,7 +6,13 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from autowsgr.combat import CombatPlan + from autowsgr.combat.fleet import ResolvedFleetSelection + from autowsgr.server.schemas import CombatPlanRequest def serialize_resources(resources: Any) -> dict[str, int]: @@ -141,6 +147,7 @@ def convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: # noq def build_combat_plan(request: Any) -> Any: """从请求构建 CombatPlan 对象。""" from autowsgr.combat import CombatPlan, NodeDecision + from autowsgr.combat.plan import parse_map_value from autowsgr.types import RepairMode def _build_node_decision(node_req: Any) -> NodeDecision: @@ -149,12 +156,14 @@ def _build_node_decision(node_req: Any) -> NodeDecision: ) node_args = {k: _build_node_decision(v) for k, v in request.node_args.items()} + map_id, entrance = parse_map_value(request.map) return CombatPlan( name=request.name, mode=request.mode, chapter=request.chapter, - map_id=request.map, + map_id=map_id, + entrance=entrance, fleet_id=request.fleet_id, fleet=request.fleet, repair_mode=[RepairMode(r) for r in request.repair_mode], @@ -164,3 +173,33 @@ def _build_node_decision(node_req: Any) -> NodeDecision: nodes=node_args, event_name=request.event_name, ) + + +def build_fleet_selection( + plan: CombatPlan, + request_plan: CombatPlanRequest | None, + *, + fleet_id: int | None = None, +) -> ResolvedFleetSelection: + """在 server 边界把 API 覆盖值转换成最终舰队选择。""" + from autowsgr.combat.fleet import fleet_slot_from_api, resolve_fleet_selection + + request_rules = request_plan.fleet_rules if request_plan is not None else None + slot_rules = ( + tuple( + fleet_slot_from_api( + rule if isinstance(rule, str) else rule.model_dump(exclude_none=True), + ) + for rule in request_rules + ) + if request_rules is not None + else None + ) + request_fleet_id = request_plan.fleet_id if request_plan is not None else None + request_fleet = request_plan.fleet if request_plan is not None else None + return resolve_fleet_selection( + plan, + fleet_id=fleet_id if fleet_id is not None else request_fleet_id, + fleet=request_fleet, + slot_rules=slot_rules, + ) diff --git a/autowsgr/ui/battle/fleet_change.py b/autowsgr/ui/battle/fleet_change.py index f0b49a12..bebcd6b2 100644 --- a/autowsgr/ui/battle/fleet_change.py +++ b/autowsgr/ui/battle/fleet_change.py @@ -11,6 +11,7 @@ import time from typing import TYPE_CHECKING +from autowsgr.combat.fleet import ShipSelector from autowsgr.infra.logger import get_logger from autowsgr.types import ShipDamageState @@ -46,8 +47,7 @@ def change_fleet( Parameters ---------- fleet_id: - 舰队编号 (2-4)。1 队不支持更换。 - ``None`` 代表不指定舰队,仅更换舰船。 + 舰队编号 (1-4)。``None`` 代表不指定舰队,仅更换舰船。 ship_names: 舰船名列表 (按槽位 0-5)。``None`` 或 ``""`` 表示该位留空。 @@ -56,9 +56,6 @@ def change_fleet( bool 始终返回 ``True``(子类可覆盖以返回失败状态)。 """ - if fleet_id == 1: - raise ValueError('不支持更换 1 队舰船编成') - if fleet_id and self.get_selected_fleet(self._ctrl.screenshot()) != fleet_id: self.select_fleet(fleet_id) time.sleep(0.5) @@ -112,4 +109,5 @@ def _change_single_ship( timeout=5.0, ) choose_page = ChooseShipPage(self._ctx) - choose_page.change_single_ship(name) + selector = ShipSelector(name=name) if name is not None else None + choose_page.change_single_ship(selector) diff --git a/autowsgr/ui/battle/fleet_change/_change.py b/autowsgr/ui/battle/fleet_change/_change.py index 8c036aff..154aa24c 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -18,8 +18,9 @@ import re import time -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING +from autowsgr.combat.fleet import FleetSlotRule, ShipSelector from autowsgr.constants import ship_name_identity from autowsgr.infra.logger import get_logger from autowsgr.ui.battle.constants import CLICK_SHIP_SLOT @@ -45,36 +46,6 @@ _SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') -# 描述一艘主选或备选舰船自己的筛选条件。 -class FleetShipOption(TypedDict, total=False): - """单艘舰船的选船规则。""" - - name: str - search_name: str - ship_type: list[str] - min_level: int - max_level: int - relaxed_constraints: bool - - -# 描述 YAML 中一个主选槽位及其位置级备选。 -class FleetSlotRule(FleetShipOption, total=False): - """YAML 槽位规则。""" - - candidates: list[str | FleetShipOption] - - -# 智能换船内部按尝试顺序使用的完整规则列表。 -class FleetSlotSelector(TypedDict): - """后端内部槽位规则,不写回 YAML。""" - - options: list[FleetShipOption] - - -# 一个槽位可以是固定舰名、带条件的规则或空槽。 -FleetSlotInput = str | FleetSlotRule | None - - # 为普通出征和决战准备页提供同一套智能换船流程。 class FleetChangeMixin(FleetDetectMixin): """准备页换船逻辑。""" @@ -83,10 +54,10 @@ class FleetChangeMixin(FleetDetectMixin): _use_search: bool = True # 执行一套六槽舰队的完整换船、排序和验证流程。 - def change_fleet( # noqa: PLR0912 + def change_fleet( self, fleet_id: int | None, - ship_names: Sequence[FleetSlotInput], + ship_names: Sequence[FleetSlotRule], ) -> bool: """返回最终舰队是否符合六个目标槽位。""" # Step 1:切换到 YAML 指定的舰队。 @@ -97,23 +68,10 @@ def change_fleet( # noqa: PLR0912 # Step 2:分别保存六个槽位的目标舰名和选船规则。 names: list[str | None] = [] - selectors: list[FleetSlotSelector | None] = [] - for raw_slot in list(ship_names)[:6]: - selector = self._extract_selector(raw_slot) - selectors.append(selector) - - # 字符串槽位直接使用该舰名。 - if isinstance(raw_slot, str): - names.append(self._normalize_ship_name(raw_slot)) - # 规则槽位从显式主选规则读取目标舰名。 - elif selector is not None: - options = selector['options'] - if options: - names.append(self._normalize_ship_name(options[0]['name'])) - else: - names.append(None) - else: - names.append(None) + selectors: list[FleetSlotRule | None] = [] + for slot_rule in list(ship_names)[:6]: + selectors.append(slot_rule) + names.append(self._normalize_ship_name(slot_rule.preferred_name)) # Step 3:不足六槽时补空,并为所有槽位分配互不重复的舰名。 names += [None] * (6 - len(names)) @@ -125,7 +83,7 @@ def change_fleet( # noqa: PLR0912 _log.error('[准备页] 目标编成无法满足同名舰唯一约束: {}', names) return False names = unique_names - # 一队最后一艘船不能移除,因此槽位 0 必须有目标舰船。 + # 第一舰队最后一艘舰船不能移除,但第一舰队本身允许更换编成。 if fleet_id == 1 and names[0] is None: raise ValueError('1 队槽位 0 不能为空') _log.info('[准备页] 目标编成: {}', names) @@ -150,11 +108,11 @@ def change_fleet( # noqa: PLR0912 self._local_fix(current, names, selectors) # Step 6:重新识别成员,再通过拖拽调整舰船顺序。 - current = self.detect_fleet() + current = self.detect_fleet(expected_names=names) self._reorder(current, names) # Step 7:最终 OCR 验证舰名、顺序、空槽和重名情况。 - current = self.detect_fleet() + current = self.detect_fleet(expected_names=names) # 最终舰队符合目标时,返回成功。 if self._validate_with_selector(current, names, selectors): _log.info('[准备页] 编成更换完成: {}', current) @@ -198,142 +156,35 @@ def _ship_identity(cls, value: object) -> str | None: normalized = cls._normalize_ship_name(value) return ship_name_identity(normalized) if normalized is not None else None - # 从一个槽位读取主选及每个备选自己的完整规则。 - @classmethod - def _extract_selector(cls, slot: object | None) -> FleetSlotSelector | None: - """把新旧槽位结构整理为内部完整规则列表。""" - # 固定舰名和空槽没有额外选船规则。 - if slot is None or isinstance(slot, str): - return None - - raw_candidates = cls._rule_field(slot, 'candidates') - candidates = list(raw_candidates) if isinstance(raw_candidates, list) else [] - - # 主选显式读取 name;旧结构缺少 name 时才取第一个字符串候选。 - primary = cls._normalize_option(slot) - if primary is None: - primary_index = next( - ( - index - for index, candidate in enumerate(candidates) - if isinstance(candidate, str) and candidate.strip() - ), - None, - ) - if primary_index is not None: - primary = cls._normalize_option( - candidates.pop(primary_index), - inherited=slot, - inherit_search=True, - ) - - # 没有主选时,结构化 candidates 仍是该位置的完整宽泛候选队列。 - options = [primary] if primary is not None else [] - - # 旧字符串备选继承槽位约束;新对象备选只使用自己的规则。 - for candidate in candidates: - option = cls._normalize_option( - candidate, - inherited=slot if isinstance(candidate, str) else None, - ) - if option is None: - continue - option['relaxed_constraints'] = True - options.append(option) - return {'options': options} if options else None - - @staticmethod - def _rule_field(rule: object, field: str) -> object: - """同时读取字典规则和 Pydantic 请求对象。""" - if isinstance(rule, dict): - return rule.get(field) - return getattr(rule, field, None) - - @classmethod - def _normalize_option( - cls, - raw: object, - *, - inherited: object | None = None, - inherit_search: bool = False, - ) -> FleetShipOption | None: - """整理一艘舰船规则,旧字符串可继承槽位公共约束。""" - raw_name = raw if isinstance(raw, str) else cls._rule_field(raw, 'name') - if not isinstance(raw_name, str) or not raw_name.strip(): - return None - - option: FleetShipOption = {'name': raw_name.strip()} - source = raw if not isinstance(raw, str) else inherited - if source is None: - return option - - if not isinstance(raw, str) or inherit_search: - raw_search_name = cls._rule_field(source, 'search_name') - if isinstance(raw_search_name, str) and raw_search_name.strip(): - option['search_name'] = raw_search_name.strip() - - raw_ship_types = cls._rule_field(source, 'ship_type') - values = [raw_ship_types] if isinstance(raw_ship_types, str) else raw_ship_types - if isinstance(values, list): - ship_types = list( - dict.fromkeys( - value.strip().lower() - for value in values - if isinstance(value, str) and value.strip() - ), - ) - if ship_types: - option['ship_type'] = ship_types - - raw_min = cls._rule_field(source, 'min_level') - raw_max = cls._rule_field(source, 'max_level') - if isinstance(raw_min, int) and raw_min > 0: - option['min_level'] = raw_min - if isinstance(raw_max, int) and raw_max > 0: - option['max_level'] = raw_max - if cls._rule_field(source, 'relaxed_constraints') is True: - option['relaxed_constraints'] = True - return option - # 按“已分配舰名优先、其余规则随后”的顺序生成本槽完整规则。 @classmethod def _slot_options( cls, name: str | None, - selector: FleetSlotSelector | dict | None, - ) -> list[FleetShipOption]: + selector: FleetSlotRule | None, + ) -> list[ShipSelector]: normalized_name = cls._normalize_ship_name(name) if selector is None: - return [{'name': normalized_name}] if normalized_name else [] + return [ShipSelector(name=normalized_name)] if normalized_name else [] - raw_options = selector.get('options') - if not isinstance(raw_options, list): - legacy_selector = cls._extract_selector(selector) - raw_options = legacy_selector['options'] if legacy_selector is not None else [] - - options = [ - option - for raw_option in raw_options - if (option := cls._normalize_option(raw_option)) is not None - ] + options = list(selector.options) target_identity = cls._ship_identity(normalized_name) options.sort( - key=lambda option: cls._ship_identity(option['name']) != target_identity, + key=lambda option: cls._ship_identity(option.name) != target_identity, ) - return options @classmethod def _slot_candidates( cls, name: str | None, - selector: FleetSlotSelector | dict | None, + selector: FleetSlotRule | None, ) -> list[str]: """返回本槽按尝试顺序排列的标准舰名。""" candidates: list[str] = [] seen: set[str] = set() for option in cls._slot_options(name, selector): - normalized = cls._normalize_ship_name(option['name']) + normalized = cls._normalize_ship_name(option.name) identity = cls._ship_identity(normalized) if normalized is not None and identity is not None and identity not in seen: candidates.append(normalized) @@ -345,7 +196,7 @@ def _slot_candidates( def _assign_unique_targets( cls, names: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> list[str | None] | None: """为每个非空槽位分配唯一舰名,候选重叠时按优先级回溯。""" # options 保存六个槽位各自按优先级排列的候选舰名。 @@ -378,10 +229,10 @@ def assign(slot: int, used: set[str]) -> bool: # 判断当前标准舰名是否符合 selector 指定的搜索名称。 @classmethod - def _matches_search_name(cls, current_name: str | None, raw_search_name: object) -> bool: + def _matches_search_name(cls, current_name: str | None, raw_search_name: str | None) -> bool: if current_name is None: return False - if not isinstance(raw_search_name, str): + if raw_search_name is None: return True if not raw_search_name.strip(): return True @@ -397,15 +248,15 @@ def _matches_search_name(cls, current_name: str | None, raw_search_name: object) def _option_for_name( cls, name: str | None, - selector: FleetSlotSelector | dict | None, - ) -> FleetShipOption | None: + selector: FleetSlotRule | None, + ) -> ShipSelector | None: """返回与实际舰名对应的独立规则。""" identity = cls._ship_identity(name) return next( ( option for option in cls._slot_options(name, selector) - if cls._ship_identity(option['name']) == identity + if cls._ship_identity(option.name) == identity ), None, ) @@ -416,10 +267,10 @@ def _select_available_candidate( cls, current: list[str | None], name: str | None, - selector: FleetSlotSelector | dict | None, + selector: FleetSlotRule | None, *, slot_to_replace: int | None = None, - ) -> tuple[str | None, FleetSlotSelector | None]: + ) -> tuple[str | None, tuple[ShipSelector, ...] | None]: """返回第一个未被其他槽位占用的候选舰名。""" # 目标舰名为空时,本槽不需要选船。 if name is None: @@ -435,18 +286,15 @@ def _select_available_candidate( } # available 保留当前舰队中尚未占用的完整规则。 available = [ - option for option in options if cls._ship_identity(option['name']) not in occupied + option for option in options if cls._ship_identity(option.name) not in occupied ] if len(available) == 0: return None, None - chosen = cls._normalize_ship_name(available[0]['name']) - if selector is None: - return chosen, None - + chosen = cls._normalize_ship_name(available[0].name) # 选船页面按顺序尝试未占用规则,各备选使用自己的约束。 - return chosen, {'options': available} + return chosen, tuple(available) # 将当前舰队成员与目标槽位一对一匹配,找出可以直接保留的舰船。 @classmethod @@ -454,7 +302,7 @@ def _match_existing_members( cls, current: list[str | None], desired: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> tuple[list[bool], set[int]]: """在当前舰队与目标槽位之间做一对一匹配。 @@ -476,7 +324,7 @@ def matches(slot: int, ship: str | None) -> bool: selector = selectors[slot] option = cls._option_for_name(desired[slot], selector) return cls._ship_identity(ship) == cls._ship_identity(desired[slot]) and ( - option is None or cls._matches_search_name(ship, option.get('search_name')) + option is None or cls._matches_search_name(ship, option.search_name) ) # 第一轮优先保留已经位于正确槽位的舰船。 @@ -511,7 +359,7 @@ def _slot_matches( cls, current_name: str | None, target: str | None, - selector: FleetSlotSelector | dict | None, + selector: FleetSlotRule | None, ) -> bool: # 目标为空时,只有当前槽也为空才算匹配。 if target is None: @@ -523,7 +371,7 @@ def _slot_matches( return False return cls._matches_search_name( current_name, - option.get('search_name'), + option.search_name, ) # 验证当前六个槽位是否完整满足目标,并拒绝队内同名舰。 @@ -532,7 +380,7 @@ def _validate_with_selector( cls, current: list[str | None], desired: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> bool: members = [cls._ship_identity(name) for name in current if name is not None] if len(members) != len(set(members)): @@ -546,7 +394,7 @@ def _find_wrong_slots( cls, current: list[str | None], names: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> list[int]: """返回所有不符合目标规则的槽位下标。""" return [i for i in range(6) if not cls._slot_matches(current[i], names[i], selectors[i])] @@ -556,7 +404,7 @@ def _replace_target( self, current: list[str | None], names: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], target_slot: int, ship_slot: int | None = None, ) -> None: @@ -596,7 +444,7 @@ def _full_align( self, current: list[str | None], names: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> None: """首次将当前成员调整成目标成员集合。""" # ok 标记当前可保留位置,matched_slots 标记已满足的目标槽位。 @@ -626,7 +474,7 @@ def _full_align( time.sleep(0.3) # Step 3:重新 OCR,检查删除舰船造成的槽位压缩和缺员。 - current[:] = self.detect_fleet() + current[:] = self.detect_fleet(expected_names=names) target_count = sum(1 for v in names if v is not None) current_count = sum(1 for v in current if v is not None) # 实际舰船少于目标数量时,逐槽补齐缺少成员。 @@ -647,7 +495,7 @@ def _local_fix( self, current: list[str | None], names: list[str | None], - selectors: list[FleetSlotSelector | dict | None], + selectors: list[FleetSlotRule | None], ) -> None: """只修正本轮识别出的错误槽位。""" # wrong 保存所有需要替换、补充或移除的槽位。 @@ -738,7 +586,7 @@ def _change_single_ship( slot: int, name: str | None, *, - selector: dict | None = None, + selector: Sequence[ShipSelector] | None = None, slot_occupied: bool = True, ) -> str | None: """返回选船页面实际选中的舰名。""" @@ -758,10 +606,20 @@ def _change_single_ship( source='编队', target='编队选船', ) - # choose_page 负责根据舰名、舰种和等级条件执行实际选船。 + # FleetChange 决定候选顺序,页面每次只执行一条明确规则。 choose_page = ChooseShipPage(self._ctx) - return choose_page.change_single_ship( - name, - use_search=self._use_search, - selector=selector, - ) + if name is None: + return choose_page.change_single_ship(None, use_search=self._use_search) + + options = tuple(selector) if selector is not None else (ShipSelector(name=name),) + for option in options: + selected = choose_page.change_single_ship( + option, + use_search=self._use_search, + ) + if selected is not None: + return selected + + candidates = [option.name for option in options] + _log.error('[准备页] 未在选船列表中找到满足规则的候选: {}', candidates) + raise RuntimeError(f'未找到满足条件的目标舰船: {candidates}') diff --git a/autowsgr/ui/battle/fleet_change/_detect.py b/autowsgr/ui/battle/fleet_change/_detect.py index 45625f2f..4063fc14 100644 --- a/autowsgr/ui/battle/fleet_change/_detect.py +++ b/autowsgr/ui/battle/fleet_change/_detect.py @@ -120,6 +120,7 @@ def detect_fleet( prepared_results.append((result, raw_text, patched_text, pool_match)) ships: list[str | None] = [None] * 6 + recognized_ocr: list[dict[str, object]] = [] for r, raw_text, text, pool_match in prepared_results: # 空文字或没有坐标的 OCR 结果无法对应舰队槽位。 @@ -140,6 +141,14 @@ def detect_fleet( context_match = self._match_context_ship_name(text, [expected_name]) if context_match is not None: matched = context_match + recognized_ocr.append( + { + 'slot': slot, + 'raw': raw_text, + 'patched': text, + 'matched': matched, + } + ) # 完整船池和目标上下文都无法识别时跳过该文字。 if matched is None: _log.debug("[准备页] OCR '{}' -> 无匹配, 跳过", raw_text) @@ -147,6 +156,10 @@ def detect_fleet( ships[slot] = matched _log.debug("[准备页] 槽位 {} OCR -> '{}'", slot, matched) + _log.info( + '[准备页] 编队 OCR 识别: {}', + recognized_ocr, + ) _log.info('[准备页] 当前舰队: {}', ships) return ships diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index 68e25fd5..923718c6 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -18,6 +18,7 @@ from autowsgr.constants import SHIPNAMES from autowsgr.infra.logger import get_logger +from autowsgr.types import ShipType from autowsgr.vision import ( MatchStrategy, PixelChecker, @@ -34,6 +35,7 @@ if TYPE_CHECKING: import numpy as np + from autowsgr.combat.fleet import ShipSelector from autowsgr.context import GameContext @@ -60,29 +62,6 @@ _SCROLL_TO_Y: float = 0.30 _OCR_MAX_ATTEMPTS: int = 3 -_SHIP_TYPE_KEYWORDS: dict[str, tuple[str, ...]] = { - 'dd': ('驱逐',), - 'cl': ('轻巡',), - 'ca': ('重巡',), - 'cav': ('航巡',), - 'clt': ('雷巡',), - 'bb': ('战列',), - 'bc': ('战巡',), - 'bbv': ('航战',), - 'cv': ('航母',), - 'cvl': ('轻母',), - 'av': ('装母',), - 'ss': ('潜艇',), - 'ssg': ('导潜',), - 'cg': ('导巡',), - 'cgaa': ('防巡',), - 'ddg': ('导驱',), - 'ddgaa': ('防驱',), - 'bm': ('重炮',), - 'cbg': ('大巡',), - 'cf': ('旗舰',), -} - PAGE_SIGNATURE = PixelSignature( name='choose_ship_page', strategy=MatchStrategy.ALL, @@ -197,104 +176,32 @@ def click_remove(self) -> None: _log.debug('[UI] 选船 → 移除舰船') self._ctrl.click(*CLICK_REMOVE_SHIP) - @classmethod - def _selection_options( # noqa: PLR0912 - cls, - name: str, - selector: dict | None, - ) -> list[dict[str, object]]: - """整理智能换船传入的独立主选和备选规则。""" - if selector is None: - return [{'name': name}] - - raw_options = selector.get('options') - if not isinstance(raw_options, list): - raw_candidates = selector.get('candidates') - raw_options = raw_candidates if isinstance(raw_candidates, list) else [name] - - target_identity = cls._normalize_ship_name(name) - options: list[dict[str, object]] = [] - for raw_option in raw_options: - if isinstance(raw_option, str): - option: dict[str, object] = {'name': raw_option.strip()} - source = selector - elif isinstance(raw_option, dict): - raw_name = raw_option.get('name') - if not isinstance(raw_name, str): - continue - option = {'name': raw_name.strip()} - source = raw_option - else: - continue - - if not option['name']: - continue - - if ( - not isinstance(raw_option, str) - or cls._normalize_ship_name(raw_option) == target_identity - ): - raw_search = source.get('search_name') - if isinstance(raw_search, str) and raw_search.strip(): - option['search_name'] = raw_search.strip() - - raw_ship_types = source.get('ship_type') - values = [raw_ship_types] if isinstance(raw_ship_types, str) else raw_ship_types - if isinstance(values, list): - ship_types = list( - dict.fromkeys( - value.strip().lower() - for value in values - if isinstance(value, str) and value.strip() - ), - ) - if ship_types: - option['ship_type'] = ship_types - - for field in ('min_level', 'max_level'): - value = source.get(field) - if isinstance(value, int) and value > 0: - option[field] = value - if source.get('relaxed_constraints') is True: - option['relaxed_constraints'] = True - options.append(option) - - options.sort( - key=lambda option: cls._normalize_ship_name(str(option['name'])) != target_identity, - ) - return options or [{'name': name}] - def change_single_ship( self, - name: str | None, + selector: ShipSelector | None, *, use_search: bool = True, - selector: dict | None = None, ) -> str | None: - """更换/移除当前槽位的舰船。 + """按一条明确规则更换舰船,或移除当前槽位舰船。 使用 DLL 行定位 + OCR 在选船列表中查找目标舰船并点击。 最多重试 ``_OCR_MAX_ATTEMPTS`` 次, 每次失败后向上滚动列表。 Parameters ---------- - name: - 目标舰船名; ``None`` 表示移除当前槽位舰船。 + selector: + FleetChange 已决定好的单条舰船选择规则;``None`` 表示移除。 use_search: 是否使用搜索框输入舰船名来过滤列表。 常规出征为 ``True`` (默认), 决战为 ``False`` (决战选船界面没有搜索框)。 - selector: - 智能换船内部规则。``options`` 中每一项分别保存舰名、 - 搜索名、允许舰种和等级范围;旧 ``candidates`` 字符串 - 列表仍兼容读取。 Returns ------- str | None 实际选中的舰船名;移除操作返回 ``None``。 """ - if name is None: + if selector is None: self.click_remove() self._wait_leave_current_page() return None @@ -303,36 +210,23 @@ def change_single_ship( _log.warning('[UI] 未提供 OCR 引擎, 无法识别选船列表') return None - options = self._selection_options(name, selector) - for option in options: - candidate = str(option['name']) - raw_search_name = option.get('search_name', candidate) - search_name = self._normalize_search_keyword(str(raw_search_name)) - ship_types = option.get('ship_type') - min_level = option.get('min_level') - max_level = option.get('max_level') - relaxed_constraints = option.get('relaxed_constraints') is True - if use_search: - self.ensure_search_box() - self.input_ship_name(search_name) - self.ensure_dismiss_keyboard() - matched = self._click_ship_in_list( - candidate, - ship_type=ship_types if isinstance(ship_types, list) else None, - min_level=min_level if isinstance(min_level, int) else None, - max_level=max_level if isinstance(max_level, int) else None, - relaxed_constraints=relaxed_constraints, - ) - if matched is not None: - self._wait_leave_current_page() - return matched - - candidates = [option['name'] for option in options] - _log.error( - '[UI] 未在选船列表中找到满足独立规则的候选: {}', - candidates, + search_name = self._normalize_search_keyword( + selector.search_name or selector.name, + ) + if use_search: + self.ensure_search_box() + self.input_ship_name(search_name) + self.ensure_dismiss_keyboard() + matched = self._click_ship_in_list( + selector.name, + ship_type=selector.ship_types or None, + min_level=selector.min_level, + max_level=selector.max_level, + relaxed_constraints=selector.relaxed_constraints, ) - raise RuntimeError(f'未找到满足条件的目标舰船: {candidates}') + if matched is not None: + self._wait_leave_current_page() + return matched @staticmethod def _normalize_hit_entry(hit: object) -> tuple[str, float, float, float]: @@ -385,7 +279,7 @@ def _click_ship_in_list( # noqa: C901, PLR0912 self, name: str, *, - ship_type: list[str] | None = None, + ship_type: tuple[ShipType, ...] | None = None, min_level: int | None = None, max_level: int | None = None, relaxed_constraints: bool = False, @@ -528,7 +422,7 @@ def _detect_ship_type_near_hit( cx: float, cy: float, row_key: float, - ) -> str | None: + ) -> ShipType | None: """在命中卡片附近 OCR 识别舰种。""" assert self._ctx.ocr is not None @@ -556,25 +450,21 @@ def _detect_ship_type_near_hit( return None @staticmethod - def _extract_ship_type_from_text(text: str) -> str | None: + def _extract_ship_type_from_text(text: str) -> ShipType | None: if not text: return None normalized = text.replace(' ', '') - for ship_type, keywords in _SHIP_TYPE_KEYWORDS.items(): - if any(keyword in normalized for keyword in keywords): + for ship_type in ShipType: + if ship_type is not ShipType.Other and ship_type.value in normalized: return ship_type return None @staticmethod def _is_ship_type_in_rule( - detected: str | None, - expected: str | list[str], + detected: ShipType | None, + expected: tuple[ShipType, ...], ) -> bool: - if detected is None: - return False - rules = [expected] if isinstance(expected, str) else expected - normalized = {rule.strip().lower() for rule in rules} - return detected in normalized or ('ss_or_ssg' in normalized and detected in {'ss', 'ssg'}) + return detected is not None and detected in expected @staticmethod def _normalize_search_keyword(name: str) -> str: diff --git a/autowsgr/ui/decisive/legacy_fleet_change.py b/autowsgr/ui/decisive/legacy_fleet_change.py index 6e12cc7b..2089d29e 100644 --- a/autowsgr/ui/decisive/legacy_fleet_change.py +++ b/autowsgr/ui/decisive/legacy_fleet_change.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from collections.abc import Sequence + from autowsgr.combat.fleet import FleetSlotRule from autowsgr.ui.decisive.preparation import DecisiveBattlePreparationPage @@ -28,19 +29,19 @@ def change_fleet_legacy( ship_names: Sequence[str | None], ) -> bool: """使用原有的完整对齐流程更换决战舰队。""" - if fleet_id == 1: - raise ValueError('不支持更换 1 队舰船编成') - - if fleet_id and page.get_selected_fleet(page._ctrl.screenshot()) != fleet_id: - page.select_fleet(fleet_id) - time.sleep(0.5) - names = [ name.strip() if isinstance(name, str) and name.strip() else None for name in list(ship_names)[:6] ] names += [None] * (6 - len(names)) - selectors: list[dict | None] = [None] * 6 + if fleet_id == 1 and names[0] is None: + raise ValueError('1 队槽位 0 不能为空') + + if fleet_id and page.get_selected_fleet(page._ctrl.screenshot()) != fleet_id: + page.select_fleet(fleet_id) + time.sleep(0.5) + + selectors: list[FleetSlotRule | None] = [None] * 6 _log.info('[决战] 使用原有换船流程,目标编成: {}', names) for attempt in range(_MAX_SET_RETRIES + 1): diff --git a/autowsgr/ui/decisive/map_controller.py b/autowsgr/ui/decisive/map_controller.py index 810df595..5b2f393f 100644 --- a/autowsgr/ui/decisive/map_controller.py +++ b/autowsgr/ui/decisive/map_controller.py @@ -705,7 +705,8 @@ def change_fleet( Parameters ---------- fleet_id: - 舰队编号 (2-4);``None`` 代表不指定舰队。1 队不支持更换。 + 舰队编号 (1-4);``None`` 代表不指定舰队。 + 更换 1 队时槽位 0 必须保留目标舰船。 ship_names: 目标舰船名列表 (按槽位 0-5);``None``/``""`` 表示该位留空。 """ diff --git a/autowsgr/ui/decisive/preparation.py b/autowsgr/ui/decisive/preparation.py index f5c086c3..2e88117f 100644 --- a/autowsgr/ui/decisive/preparation.py +++ b/autowsgr/ui/decisive/preparation.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING +from autowsgr.combat.fleet import exact_fleet_rules from autowsgr.ui.battle.preparation import BattlePreparationPage from autowsgr.ui.decisive.legacy_fleet_change import change_fleet_legacy @@ -67,5 +68,6 @@ def change_fleet( ) -> bool: """按配置选择决战原有流程或新的换船算法。""" if self._config.use_new_fleet_change_algorithm: - return super().change_fleet(fleet_id, ship_names) + rules = exact_fleet_rules([name for name in ship_names if name]) + return super().change_fleet(fleet_id, rules) return change_fleet_legacy(self, fleet_id, ship_names) diff --git a/examples/change_fleet.py b/examples/change_fleet.py index 85dc0032..94529328 100644 --- a/examples/change_fleet.py +++ b/examples/change_fleet.py @@ -3,6 +3,7 @@ 修改第 2 舰队的舰船配置。 """ +from autowsgr.combat.fleet import exact_fleet_rules from autowsgr.ops import goto_page from autowsgr.scheduler import launch from autowsgr.ui import BattlePreparationPage, PageName @@ -15,4 +16,4 @@ page = BattlePreparationPage(ctx) -page.change_fleet(2, ['U-47', 'U-96']) +page.change_fleet(2, exact_fleet_rules(['U-47', 'U-96'])) diff --git a/examples/week.py b/examples/week.py index abe28454..4c78bca6 100644 --- a/examples/week.py +++ b/examples/week.py @@ -1,5 +1,6 @@ import sys +from autowsgr.combat.fleet import resolve_fleet_selection from autowsgr.ops.normal_fight import NormalFightRunner, get_normal_fight_plan from autowsgr.scheduler import launch @@ -13,6 +14,6 @@ runner = NormalFightRunner( ctx, plan, - fleet_id=2, + resolve_fleet_selection(plan, fleet_id=2), ) runner.run_for_times_condition(1, last_point[i]) diff --git a/testing/combat/test_combat.py b/testing/combat/test_combat.py index 9ac9c356..fdc5d044 100644 --- a/testing/combat/test_combat.py +++ b/testing/combat/test_combat.py @@ -7,6 +7,7 @@ import pytest from autowsgr.combat.actions import check_blood +from autowsgr.combat.fleet import FleetSlotRule, ShipSelector from autowsgr.combat.history import ( CombatEvent, CombatHistory, @@ -36,7 +37,7 @@ build_transitions, resolve_successors, ) -from autowsgr.types import Formation, RepairMode, ShipDamageState +from autowsgr.types import Formation, RepairMode, ShipDamageState, ShipType if TYPE_CHECKING: @@ -451,7 +452,7 @@ def test_presets_must_be_list(self, invalid_presets: object): def test_empty_presets_is_preserved(self): """空列表由上层决定业务含义。""" plan = CombatPlan.from_dict({'fleet_presets': []}) - assert plan.fleet_presets == [] + assert plan.fleet_presets == () def test_preset_content_is_normalized(self): """旧字符串候选迁移为显式主选和完整备选规则。""" @@ -473,31 +474,31 @@ def test_preset_content_is_normalized(self): }, ) - assert plan.fleet_presets == [ - { - 'name': '测试舰队', - 'ships': [ - '飞龙·改', - { - 'name': '岛风', - 'candidates': [ - { - 'name': '黑潮', - 'ship_type': ['dd'], - 'min_level': 100, - }, - { - 'name': '岛风', - 'ship_type': ['dd'], - 'min_level': 100, - }, - ], - 'ship_type': ['dd'], - 'min_level': 100, - }, - ], - }, - ] + assert plan.fleet_presets is not None + preset = plan.fleet_presets[0] + assert preset.name == '测试舰队' + assert preset.slots[0] == FleetSlotRule(primary=ShipSelector(name='飞龙·改')) + assert preset.slots[1] == FleetSlotRule( + primary=ShipSelector( + name='岛风', + ship_types=(ShipType.DD,), + min_level=100, + ), + candidates=( + ShipSelector( + name='黑潮', + ship_types=(ShipType.DD,), + min_level=100, + relaxed_constraints=True, + ), + ShipSelector( + name='岛风', + ship_types=(ShipType.DD,), + min_level=100, + relaxed_constraints=True, + ), + ), + ) def test_independent_candidate_rules_are_preserved(self): """主选和每个备选分别保留自己的舰种及等级范围。""" @@ -533,33 +534,30 @@ def test_independent_candidate_rules_are_preserved(self): }, ) - assert plan.fleet_presets == [ - { - 'name': '潜艇队', - 'ships': [ - { - 'name': 'U-47', - 'ship_type': ['ss', 'ssg'], - 'min_level': 100, - 'max_level': 110, - 'candidates': [ - { - 'name': 'U-96', - 'ship_type': ['ss'], - 'min_level': 90, - 'max_level': 105, - }, - { - 'name': 'U-47', - 'ship_type': ['ss'], - 'min_level': 100, - 'max_level': 110, - }, - ], - }, - ], - }, - ] + assert plan.fleet_presets is not None + slot = plan.fleet_presets[0].slots[0] + assert slot.primary == ShipSelector( + name='U-47', + ship_types=(ShipType.SS, ShipType.SSG), + min_level=100, + max_level=110, + ) + assert slot.candidates == ( + ShipSelector( + name='U-96', + ship_types=(ShipType.SS,), + min_level=90, + max_level=105, + relaxed_constraints=True, + ), + ShipSelector( + name='U-47', + ship_types=(ShipType.SS,), + min_level=100, + max_level=110, + relaxed_constraints=True, + ), + ) def test_candidate_only_slots_are_preserved(self): """结构化纯备选位置不把第一候选提升为严格主选。""" @@ -586,44 +584,64 @@ def test_candidate_only_slots_are_preserved(self): }, ) - assert plan.fleet_presets == [ + assert plan.fleet_presets is not None + slot = plan.fleet_presets[0].slots[0] + assert slot.primary is None + assert slot.candidates == ( + ShipSelector( + name='胡德', + ship_types=(ShipType.BC,), + relaxed_constraints=True, + ), + ShipSelector( + name='扶桑', + ship_types=(ShipType.BB,), + min_level=80, + max_level=110, + relaxed_constraints=True, + ), + ) + + def test_slot_fields_are_converted_to_domain_model(self): + """解析阶段把槽位字段转换成 canonical model。""" + plan = CombatPlan.from_dict( { - 'name': '纯备选', - 'ships': [ + 'fleet_presets': [ { - 'candidates': [ - {'name': '胡德', 'ship_type': ['bc']}, - { - 'name': '扶桑', - 'ship_type': ['bb'], - 'min_level': 80, - 'max_level': 110, - }, + 'ships': [ + {'name': '契卡洛夫', 'max_level': 110}, ], }, ], }, - ] + ) + assert plan.fleet_presets is not None + assert plan.fleet_presets[0].slots == ( + FleetSlotRule( + primary=ShipSelector(name='契卡洛夫', max_level=110), + ), + ) - def test_unknown_slot_fields_are_preserved(self): - """解析阶段不删除槽位中的其他字段。""" + def test_legacy_primary_keeps_search_name(self): + """旧字符串主选迁移时保留顶层搜索名。""" plan = CombatPlan.from_dict( { 'fleet_presets': [ { 'ships': [ - {'name': '契卡洛夫', 'max_level': 110}, + { + 'search_name': '契卡洛夫', + 'candidates': ['85工程', '岛风'], + }, ], }, ], }, ) - assert plan.fleet_presets == [ - { - 'name': '', - 'ships': [{'name': '契卡洛夫', 'max_level': 110}], - }, - ] + + assert plan.fleet_presets is not None + slot = plan.fleet_presets[0].slots[0] + assert slot.primary == ShipSelector(name='85工程', search_name='契卡洛夫') # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/testing/ops/event_fight.py b/testing/ops/event_fight.py index 26c927ef..64cbae1b 100644 --- a/testing/ops/event_fight.py +++ b/testing/ops/event_fight.py @@ -56,6 +56,7 @@ from loguru import logger from autowsgr.combat import CombatMode, CombatPlan, NodeDecision, RuleEngine +from autowsgr.combat.fleet import resolve_fleet_selection from autowsgr.ops.event_fight import EventFightRunner from autowsgr.types import ConditionFlag, FightCondition, Formation, RepairMode from testing.ops._framework import launch_for_test @@ -242,6 +243,7 @@ def main() -> None: runner = EventFightRunner( ctx, plan, + resolve_fleet_selection(plan), map_code=map_code, entrance=entrance, ) diff --git a/testing/ops/normal_fight.py b/testing/ops/normal_fight.py index 005af99f..7612dabd 100644 --- a/testing/ops/normal_fight.py +++ b/testing/ops/normal_fight.py @@ -49,6 +49,7 @@ from loguru import logger from autowsgr.combat import CombatMode, CombatPlan, NodeDecision, RuleEngine +from autowsgr.combat.fleet import resolve_fleet_selection from autowsgr.ops import NormalFightRunner from autowsgr.types import ConditionFlag, FightCondition, Formation, RepairMode from testing.ops._framework import launch_for_test @@ -191,7 +192,7 @@ def main() -> None: logger.info('=' * 50) # ── 初始化引擎 ── - runner = NormalFightRunner(ctx, plan) + runner = NormalFightRunner(ctx, plan, resolve_fleet_selection(plan)) # ── 运行战斗 ── results: list = [] diff --git a/testing/ops/scheduler.py b/testing/ops/scheduler.py index 4179313f..4b55e66a 100644 --- a/testing/ops/scheduler.py +++ b/testing/ops/scheduler.py @@ -42,6 +42,7 @@ from loguru import logger from autowsgr.combat import CombatPlan +from autowsgr.combat.fleet import resolve_fleet_selection from autowsgr.ops.event_fight import EventFightRunner from autowsgr.scheduler import FightTask, TaskScheduler from autowsgr.types import ConditionFlag @@ -138,7 +139,12 @@ def main() -> None: logger.info('已加载计划: {} ({})', plan.name, args.plan) # ── 构建 Runner ── - runner = EventFightRunner(ctx, plan, map_code=args.map_code, fleet_id=2) + runner = EventFightRunner( + ctx, + plan, + resolve_fleet_selection(plan, fleet_id=2), + map_code=args.map_code, + ) # ── 构建调度器 ── scheduler = TaskScheduler( diff --git a/testing/ops/test_normal_fight_unit.py b/testing/ops/test_normal_fight_unit.py index a7bc3523..1408255a 100644 --- a/testing/ops/test_normal_fight_unit.py +++ b/testing/ops/test_normal_fight_unit.py @@ -11,6 +11,12 @@ import pytest from autowsgr.combat import CombatMode, CombatPlan +from autowsgr.combat.fleet import ( + FleetSelectionSource, + FleetSlotRule, + ShipSelector, + resolve_fleet_selection, +) from autowsgr.infra import ActionFailedError from autowsgr.ops.normal_fight import NormalFightRunner, _require_fleet_change @@ -54,10 +60,14 @@ def test_plan_preset_is_used_without_api_override(self): }, ) - runner = NormalFightRunner(_make_ctx(), plan) + selection = resolve_fleet_selection(plan) + runner = NormalFightRunner(_make_ctx(), plan, selection) - assert runner._fleet_rules == ships - assert runner._primary_names_from_rules(ships) == ['U-47'] + assert runner._fleet_selection is selection + assert selection.source is FleetSelectionSource.PLAN_PRESET + assert selection.slot_rules is not None + assert selection.slot_rules[0].primary == ShipSelector(name='U-47') + assert selection.primary_names == ['U-47'] def test_api_rules_override_plan_preset(self): plan = CombatPlan.from_dict( @@ -70,23 +80,73 @@ def test_api_rules_override_plan_preset(self): ], }, ) - override = [{'name': '岛风'}] + override = (FleetSlotRule(primary=ShipSelector(name='岛风')),) + selection = resolve_fleet_selection(plan, slot_rules=override) - runner = NormalFightRunner(_make_ctx(), plan, fleet_rules=override) + runner = NormalFightRunner(_make_ctx(), plan, selection) - assert runner._fleet_rules == override + assert runner._fleet_selection.slot_rules == override + assert runner._fleet_selection.source is FleetSelectionSource.OVERRIDE_RULES def test_candidate_only_slot_has_no_fixed_primary_name(self): - rules = [ + rules = ( + FleetSlotRule( + candidates=( + ShipSelector(name='胡德', relaxed_constraints=True), + ShipSelector(name='扶桑', relaxed_constraints=True), + ), + ), + ) + selection = resolve_fleet_selection( + CombatPlan(), + slot_rules=rules, + ) + + assert selection.primary_names == [None] + + @pytest.mark.parametrize( + ('fleet', 'slot_rules', 'expected_source'), + [ + (['岛风'], None, FleetSelectionSource.OVERRIDE_FLEET), + ( + ['岛风'], + (FleetSlotRule(primary=ShipSelector(name='雪风')),), + FleetSelectionSource.OVERRIDE_RULES, + ), + ], + ) + def test_override_priority_is_centralized( + self, + fleet: list[str], + slot_rules: tuple[FleetSlotRule, ...] | None, + expected_source: FleetSelectionSource, + ): + plan = CombatPlan.from_dict( { - 'candidates': [ - {'name': '胡德'}, - {'name': '扶桑'}, - ], + 'fleet': ['飞龙'], + 'fleet_presets': [{'ships': [{'name': 'U-47'}]}], }, - ] + ) - assert NormalFightRunner._primary_names_from_rules(rules) == [None] + selection = resolve_fleet_selection( + plan, + fleet=fleet, + slot_rules=slot_rules, + ) + + assert selection.source is expected_source + + def test_plan_preset_has_priority_over_plain_plan_fleet(self): + plan = CombatPlan.from_dict( + { + 'fleet': ['飞龙'], + 'fleet_presets': [{'ships': [{'name': 'U-47'}]}], + }, + ) + + selection = resolve_fleet_selection(plan) + + assert selection.source is FleetSelectionSource.PLAN_PRESET class TestEventNormalMerge: @@ -94,7 +154,7 @@ class TestEventNormalMerge: def test_event_branch_hard(self): plan = CombatPlan.from_dict({'event': '20260730', 'chapter': 'H', 'map': '1a'}) - runner = NormalFightRunner(_make_ctx(), plan) + runner = NormalFightRunner(_make_ctx(), plan, resolve_fleet_selection(plan)) assert runner._is_event is True assert plan.mode == CombatMode.EVENT assert runner._map_code == 'H1' @@ -102,21 +162,21 @@ def test_event_branch_hard(self): def test_event_branch_easy(self): plan = CombatPlan.from_dict({'event': '20260730', 'chapter': 'E', 'map': '3b'}) - runner = NormalFightRunner(_make_ctx(), plan) + runner = NormalFightRunner(_make_ctx(), plan, resolve_fleet_selection(plan)) assert runner._is_event is True assert runner._map_code == 'E3' assert runner._entrance == 'beta' def test_event_no_entrance(self): plan = CombatPlan.from_dict({'event': '20260212', 'chapter': 'H', 'map': 5}) - runner = NormalFightRunner(_make_ctx(), plan) + runner = NormalFightRunner(_make_ctx(), plan, resolve_fleet_selection(plan)) assert runner._is_event is True assert runner._entrance is None assert runner._map_code == 'H5' def test_normal_branch(self): plan = CombatPlan.from_dict({'chapter': 2, 'map': 1}) - runner = NormalFightRunner(_make_ctx(), plan) + runner = NormalFightRunner(_make_ctx(), plan, resolve_fleet_selection(plan)) assert runner._is_event is False assert plan.mode == CombatMode.NORMAL assert runner._entrance is None @@ -130,7 +190,7 @@ def test_inherits_normal_runner(self): from autowsgr.ops.event_fight import EventFightRunner plan = CombatPlan.from_dict({'event': '20260730', 'chapter': 'H', 'map': '1a'}) - runner = EventFightRunner(_make_ctx(), plan) + runner = EventFightRunner(_make_ctx(), plan, resolve_fleet_selection(plan)) assert isinstance(runner, NormalFightRunner) assert runner._is_event is True assert runner._map_code == 'H1' @@ -139,7 +199,12 @@ def test_entrance_override(self): from autowsgr.ops.event_fight import EventFightRunner plan = CombatPlan.from_dict({'event': '20260730', 'chapter': 'H', 'map': '1a'}) - runner = EventFightRunner(_make_ctx(), plan, entrance='beta') + runner = EventFightRunner( + _make_ctx(), + plan, + resolve_fleet_selection(plan), + entrance='beta', + ) # override 回填 plan.entrance (alpha→'a', beta→'b') assert plan.entrance == 'b' assert runner._entrance == 'beta' diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index 67354fa5..6d0b3d31 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -3,7 +3,18 @@ import pytest from pydantic import ValidationError -from autowsgr.server.schemas import FleetRuleRequest +from autowsgr.combat import CombatPlan +from autowsgr.combat.fleet import ( + FleetSelectionSource, + fleet_slot_from_api, +) +from autowsgr.server.schemas import ( + CombatPlanRequest, + FleetRuleRequest, + NodeDecisionRequest, +) +from autowsgr.server.serializers import build_combat_plan, build_fleet_selection +from autowsgr.types import ShipType def test_new_fleet_rule_keeps_independent_candidates(): @@ -68,6 +79,10 @@ def test_candidate_only_fleet_rule_is_valid(): {'name': '扶桑', 'min_level': 80, 'max_level': 110}, ], } + slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) + assert slot.primary is None + assert [candidate.name for candidate in slot.candidates] == ['胡德', '扶桑'] + assert all(candidate.relaxed_constraints for candidate in slot.candidates) def test_empty_fleet_slot_is_rejected(): @@ -91,24 +106,15 @@ def test_candidate_only_slot_rejects_primary_constraints(): ) -def test_legacy_candidate_names_are_migrated(): - rule = FleetRuleRequest.model_validate( - { - 'candidates': [' 岛风 ', '雪风'], - 'ship_type': 'DD', - 'min_level': 80, - }, - ) - - assert rule.name == '岛风' - assert rule.ship_type == ['dd'] - assert [candidate.model_dump(exclude_none=True) for candidate in rule.candidates] == [ - { - 'name': '雪风', - 'ship_type': ['dd'], - 'min_level': 80, - }, - ] +def test_api_rejects_legacy_candidate_names(): + with pytest.raises(ValidationError): + FleetRuleRequest.model_validate( + { + 'candidates': [' 岛风 ', '雪风'], + 'ship_type': 'DD', + 'min_level': 80, + }, + ) def test_invalid_candidate_ship_type_is_rejected(): @@ -124,3 +130,136 @@ def test_invalid_candidate_ship_type_is_rejected(): ], }, ) + + +@pytest.mark.parametrize( + ('code', 'expected'), + [ + ('ap', (ShipType.NAP,)), + ('bbg', (ShipType.BG,)), + ('sc', (ShipType.SC,)), + ('ddg', (ShipType.ASDG,)), + ('ddgaa', (ShipType.AADG,)), + ('cg', (ShipType.KP,)), + ('cgaa', (ShipType.CG,)), + ], +) +def test_api_ship_type_code_maps_to_domain_enum( + code: str, + expected: tuple[ShipType, ...], +): + rule = FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': [code]}) + slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) + + assert slot.primary is not None + assert slot.primary.ship_types == expected + + +def test_removed_cf_ship_type_is_rejected(): + with pytest.raises(ValidationError, match='ship_type 不合法'): + FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': ['cf']}) + + +def test_yaml_and_api_candidate_only_rules_share_canonical_model(): + """YAML 与 API 的纯备选结构在入口转换后应完全一致。""" + raw_rule = { + 'candidates': [ + {'name': '胡德', 'ship_type': ['bc']}, + {'name': '扶桑', 'min_level': 80, 'max_level': 110}, + ], + } + yaml_plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'name': '纯备选', + 'ships': [raw_rule], + }, + ], + }, + ) + request = CombatPlanRequest(fleet_rules=[FleetRuleRequest.model_validate(raw_rule)]) + + selection = build_fleet_selection(CombatPlan(), request) + + assert yaml_plan.fleet_presets is not None + assert selection.slot_rules == yaml_plan.fleet_presets[0].slots + assert selection.source is FleetSelectionSource.OVERRIDE_RULES + + +@pytest.mark.parametrize( + ('top_level_id', 'request_id', 'plan_id', 'expected'), + [ + (3, 2, 1, 3), + (None, 2, 1, 2), + (None, None, 1, 1), + ], +) +def test_event_fleet_id_priority_is_resolved_at_server_boundary( + top_level_id: int | None, + request_id: int | None, + plan_id: int, + expected: int, +): + """活动顶层覆盖、API plan 和 YAML plan 使用统一优先级。""" + plan = CombatPlan(fleet_id=plan_id) + request = ( + CombatPlanRequest(fleet_id=request_id) + if request_id is not None + else None + ) + + selection = build_fleet_selection( + plan, + request, + fleet_id=top_level_id, + ) + + assert selection.fleet_id == expected + + +def test_node_decision_request_keeps_yaml_supported_fields(): + decision = NodeDecisionRequest.model_validate( + { + 'enemy_rules': ['(BB > 0) => retreat'], + 'enemy_formation_rules': [['(line_ahead)', 'retreat']], + 'SL_when_spot_enemy_fails': True, + 'SL_when_enter_fight': True, + 'formation_when_spot_enemy_fails': 3, + }, + ) + + assert decision.enemy_rules == ['(BB > 0) => retreat'] + assert decision.enemy_formation_rules == [['(line_ahead)', 'retreat']] + assert decision.SL_when_spot_enemy_fails is True + assert decision.SL_when_enter_fight is True + assert decision.formation_when_spot_enemy_fails == 3 + + +def test_api_combat_plan_parses_event_entrance_and_node_fields(): + request = CombatPlanRequest( + mode='event', + chapter='H', + map='1a', + node_defaults=NodeDecisionRequest( + enemy_rules=['(BB > 0) => retreat'], + SL_when_spot_enemy_fails=True, + formation_when_spot_enemy_fails=3, + ), + node_args={ + 'A': NodeDecisionRequest( + enemy_formation_rules=[['(line_ahead)', 'retreat']], + SL_when_enter_fight=True, + ), + }, + ) + + plan = build_combat_plan(request) + + assert plan.map_id == 1 + assert plan.entrance == 'a' + assert plan.default_node.enemy_rules is not None + assert plan.default_node.SL_when_spot_enemy_fails is True + assert plan.default_node.formation_when_spot_enemy_fails.value == 3 + assert plan.nodes['A'].formation_rules is not None + assert plan.nodes['A'].SL_when_enter_fight is True diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index cc8e4c0c..3c9aa372 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -3,15 +3,22 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import numpy as np import pytest +from autowsgr.combat.fleet import ( + FleetSlotRule, + ShipSelector, + exact_fleet_rules, + fleet_slot_from_api, +) from autowsgr.context import GameContext from autowsgr.emulator import AndroidController from autowsgr.infra import DecisiveConfig from autowsgr.server.schemas import FleetRuleRequest +from autowsgr.types import ShipType from autowsgr.ui.battle.base import PAGE_SIGNATURE from autowsgr.ui.battle.constants import ( AUTO_SUPPLY_PROBE, @@ -66,6 +73,29 @@ def _make_ctx(ctrl: AndroidController, ocr: OCREngine | None = None) -> GameCont return GameContext(ctrl=ctrl, config=MagicMock(), ocr=ocr) +def _rule(raw: dict[str, object]) -> FleetSlotRule: + """把 API 规则转换成 UI 实际接收的 canonical model。""" + dto = FleetRuleRequest.model_validate(raw) + return fleet_slot_from_api(dto.model_dump(exclude_none=True)) + + +def _candidate_rule( + *names: str, + min_level: int | None = None, +) -> FleetSlotRule: + """构造保持原顺序的宽泛备选规则。""" + return FleetSlotRule( + candidates=tuple( + ShipSelector( + name=name, + min_level=min_level, + relaxed_constraints=True, + ) + for name in names + ), + ) + + def _set_pixel(screen: np.ndarray, rx: float, ry: float, rgb: tuple[int, int, int]) -> None: """在相对坐标处设置像素颜色(与 PixelChecker.get_pixel 使用相同算法)。""" h, w = screen.shape[:2] @@ -388,14 +418,32 @@ def test_user_ship_name_alias_is_used_for_final_fleet_detection(self): set_user_ship_name_aliases({'契卡洛夫': '85工程'}) try: - detected = page.detect_fleet( - np.zeros((720, 1280, 3), dtype=np.uint8), - expected_names=['契卡洛夫'], - ) + with patch( + 'autowsgr.ui.battle.fleet_change._detect._log.info', + ) as log_info: + detected = page.detect_fleet( + np.zeros((720, 1280, 3), dtype=np.uint8), + expected_names=['契卡洛夫'], + ) finally: set_user_ship_name_aliases({}) assert detected == ['85工程', None, None, None, None, None] + log_info.assert_any_call( + '[准备页] 编队 OCR 识别: {}', + [ + { + 'slot': 0, + 'raw': '契卡洛夫', + 'patched': '契卡洛夫', + 'matched': '85工程', + } + ], + ) + log_info.assert_any_call( + '[准备页] 当前舰队: {}', + ['85工程', None, None, None, None, None], + ) # ───────────────────────────────────────────── @@ -435,6 +483,34 @@ def test_original_flow_changes_ship_and_verifies_result(self): ) page._reorder.assert_called_once_with(target_fleet, target_fleet) + def test_original_flow_changes_first_fleet_before_removing_extra_ship(self): + page = MagicMock() + fleet_a_b = ['A', 'B', None, None, None, None] + fleet_c = ['C', None, None, None, None, None] + page.get_selected_fleet.return_value = 1 + page.detect_fleet.side_effect = [fleet_a_b, fleet_c, fleet_c] + page._validate_with_selector.side_effect = [False, True] + page._match_existing_members.return_value = ([False] * 6, set()) + page._change_single_ship.side_effect = ['C', None] + + with patch('autowsgr.ui.decisive.legacy_fleet_change.time.sleep'): + assert change_fleet_legacy(page, 1, ['C']) + + actions = [ + (item.args[0], item.args[1], item.kwargs['slot_occupied']) + for item in page._change_single_ship.call_args_list + ] + assert actions == [(0, 'C', True), (1, None, True)] + + def test_original_flow_rejects_empty_first_fleet(self): + page = MagicMock() + + with pytest.raises(ValueError, match='1 队槽位 0 不能为空'): + change_fleet_legacy(page, 1, []) + + page.detect_fleet.assert_not_called() + page.select_fleet.assert_not_called() + def test_decisive_uses_original_flow_by_default(self): page = DecisiveBattlePreparationPage( _make_ctx(MagicMock(spec=AndroidController)), @@ -462,7 +538,7 @@ def test_decisive_uses_new_flow_when_enabled(self): ) as new_change: assert page.change_fleet(None, ['A']) - new_change.assert_called_once_with(None, ['A']) + new_change.assert_called_once_with(None, exact_fleet_rules(['A'])) # ───────────────────────────────────────────── @@ -487,12 +563,15 @@ def test_custom_name_search_accepts_standard_name_result(self): patch.object(page, '_change_single_ship', return_value='85工程') as change_ship, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): - assert page.change_fleet(1, [{'candidates': ['契卡洛夫']}]) + assert page.change_fleet( + 1, + [_rule({'candidates': [{'name': '契卡洛夫'}]})], + ) assert change_ship.call_args.args[:2] == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector']['options'] == [ - {'name': '契卡洛夫'}, - ] + assert change_ship.call_args.kwargs['selector'] == ( + ShipSelector(name='契卡洛夫', relaxed_constraints=True), + ) def test_existing_group_variant_is_reordered_without_reselection(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) @@ -514,7 +593,13 @@ def move_ship(src: int, dst: int, current: list[str | None]) -> None: patch.object(page, '_circular_move', side_effect=move_ship) as circular_move, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): - assert page.change_fleet(1, [{'candidates': ['契卡洛夫']}, '岛风']) + assert page.change_fleet( + 1, + [ + _rule({'candidates': [{'name': '契卡洛夫'}]}), + *exact_fleet_rules(['岛风']), + ], + ) change_ship.assert_not_called() assert circular_move.call_args.args[:2] == (1, 0) @@ -539,23 +624,19 @@ def test_first_fleet_replaces_before_removing_extra_ship(self): ) as change_ship, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): - assert page.change_fleet(1, ['C']) + assert page.change_fleet(1, exact_fleet_rules(['C'])) actions = [ (item.args[0], item.args[1], item.kwargs['slot_occupied']) for item in change_ship.call_args_list ] assert actions == [(0, 'C', True), (1, None, True)] - assert all(not item.args and not item.kwargs for item in detect.call_args_list) - - def test_first_fleet_slot_zero_cannot_be_empty(self): - page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) - - with ( - patch.object(page, 'get_selected_fleet', return_value=1), - pytest.raises(ValueError, match='1 队槽位 0 不能为空'), - ): - page.change_fleet(1, [None, 'B']) + assert detect.call_args_list == [ + call(), + call(expected_names=fleet_c), + call(expected_names=fleet_c), + call(expected_names=fleet_c), + ] def test_first_fleet_cannot_be_empty(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) @@ -565,7 +646,7 @@ def test_first_fleet_cannot_be_empty(self): patch.object(page, 'detect_fleet') as detect, pytest.raises(ValueError, match='1 队槽位 0 不能为空'), ): - page.change_fleet(1, []) + page.change_fleet(1, ()) detect.assert_not_called() @@ -574,7 +655,7 @@ def test_input_over_six_slots_is_truncated(self): target = ['A', 'B', 'C', 'D', 'E', 'F'] with patch.object(page, 'detect_fleet', return_value=target) as detect: - assert page.change_fleet(None, [*target, 'G']) + assert page.change_fleet(None, exact_fleet_rules([*target, 'G'])) detect.assert_called_once_with() @@ -582,7 +663,7 @@ def test_duplicate_fixed_names_fail_before_ocr(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) with patch.object(page, 'detect_fleet') as detect: - assert not page.change_fleet(None, ['A', 'A']) + assert not page.change_fleet(None, exact_fleet_rules(['A', 'A'])) detect.assert_not_called() @@ -597,7 +678,7 @@ def test_failed_verification_uses_two_local_retries(self): patch.object(page, '_reorder'), patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): - assert not page.change_fleet(None, ['A']) + assert not page.change_fleet(None, exact_fleet_rules(['A'])) assert full_align.call_count == 1 assert local_fix.call_count == 2 @@ -618,7 +699,7 @@ def test_normalize_ship_name(self, raw: object, expected: str | None): assert BattlePreparationPage._normalize_ship_name(raw) == expected def test_primary_and_candidates_keep_independent_rules(self): - selector = BattlePreparationPage._extract_selector( + selector = _rule( { 'name': '密苏里', 'candidates': [ @@ -641,33 +722,31 @@ def test_primary_and_candidates_keep_independent_rules(self): }, ) - assert selector == { - 'options': [ - { - 'name': '密苏里', - 'ship_type': ['bb'], - 'min_level': 100, - 'max_level': 110, - }, - { - 'name': '衣阿华', - 'ship_type': ['bc'], - 'min_level': 90, - 'max_level': 105, - 'relaxed_constraints': True, - }, - { - 'name': '密苏里', - 'ship_type': ['bb'], - 'min_level': 80, - 'max_level': 110, - 'relaxed_constraints': True, - }, - ], - } + assert selector.primary == ShipSelector( + name='密苏里', + ship_types=(ShipType.BB,), + min_level=100, + max_level=110, + ) + assert selector.candidates == ( + ShipSelector( + name='衣阿华', + ship_types=(ShipType.BC,), + min_level=90, + max_level=105, + relaxed_constraints=True, + ), + ShipSelector( + name='密苏里', + ship_types=(ShipType.BB,), + min_level=80, + max_level=110, + relaxed_constraints=True, + ), + ) def test_candidate_only_rules_keep_order_and_relax_constraints(self): - rule = FleetRuleRequest.model_validate( + rule = _rule( { 'candidates': [ { @@ -684,47 +763,76 @@ def test_candidate_only_rules_keep_order_and_relax_constraints(self): }, ) - assert BattlePreparationPage._extract_selector(rule) == { - 'options': [ - { - 'name': '胡德', - 'ship_type': ['bc'], - 'min_level': 90, - 'relaxed_constraints': True, - }, - { - 'name': '扶桑', - 'ship_type': ['bb'], - 'max_level': 110, - 'relaxed_constraints': True, - }, - ], - } + assert rule.primary is None + assert rule.candidates == ( + ShipSelector( + name='胡德', + ship_types=(ShipType.BC,), + min_level=90, + relaxed_constraints=True, + ), + ShipSelector( + name='扶桑', + ship_types=(ShipType.BB,), + max_level=110, + relaxed_constraints=True, + ), + ) - def test_candidate_only_slots_use_backtracking(self): - selectors = [ - BattlePreparationPage._extract_selector( - FleetRuleRequest.model_validate( + def test_existing_strict_primary_is_reused_without_reselection(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = _rule( + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 100, + 'max_level': 110, + }, + ) + current = ['密苏里', None, None, None, None, None] + + with ( + patch.object(page, 'detect_fleet', return_value=current), + patch.object(page, '_change_single_ship') as change_ship, + ): + assert page.change_fleet(None, [rule]) + + change_ship.assert_not_called() + + def test_existing_candidate_only_ship_keeps_relaxed_constraints(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = _rule( + { + 'candidates': [ { - 'candidates': [ - {'name': '胡德'}, - {'name': '扶桑'}, - ], + 'name': '胡德', + 'ship_type': ['BC'], + 'min_level': 90, }, - ), - ), - BattlePreparationPage._extract_selector( - FleetRuleRequest.model_validate( - {'candidates': [{'name': '胡德'}]}, - ), - ), + ], + }, + ) + current = ['胡德', None, None, None, None, None] + + with ( + patch.object(page, 'detect_fleet', return_value=current), + patch.object(page, '_change_single_ship') as change_ship, + ): + assert page.change_fleet(None, [rule]) + + change_ship.assert_not_called() + + def test_candidate_only_slots_use_backtracking(self): + selectors = [ + _candidate_rule('胡德', '扶桑'), + _candidate_rule('胡德'), None, None, None, None, ] names = [ - selector['options'][0]['name'] if selector is not None else None + selector.preferred_name if selector is not None else None for selector in selectors ] @@ -735,9 +843,9 @@ def test_candidate_only_slots_use_backtracking(self): def test_overlapping_priorities_use_backtracking(self): names = ['A', 'A', None, None, None, None] - selectors: list[dict | None] = [ - {'candidates': ['A', 'B']}, - {'candidates': ['A']}, + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('A', 'B'), + _candidate_rule('A'), None, None, None, @@ -755,9 +863,9 @@ def test_overlapping_priorities_use_backtracking(self): def test_same_candidate_in_two_slots_is_impossible(self): names = ['岛风', '岛风', None, None, None, None] - selectors: list[dict | None] = [ - {'candidates': ['岛风']}, - {'candidates': ['岛风']}, + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('岛风'), + _candidate_rule('岛风'), None, None, None, @@ -776,20 +884,19 @@ def test_occupied_name_is_removed_from_slot_candidates(self): selected, selector = BattlePreparationPage._select_available_candidate( ['岛风', None, None, None, None, None], '岛风', - {'candidates': ['岛风', '雪风']}, + _candidate_rule('岛风', '雪风'), ) assert selected == '雪风' - assert selector is not None - assert selector['options'] == [ - {'name': '雪风', 'relaxed_constraints': True}, - ] + assert selector == ( + ShipSelector(name='雪风', relaxed_constraints=True), + ) def test_replacing_same_slot_may_keep_current_name(self): selected, _selector = BattlePreparationPage._select_available_candidate( ['岛风', None, None, None, None, None], '岛风', - {'candidates': ['岛风', '雪风']}, + _candidate_rule('岛风', '雪风'), slot_to_replace=0, ) @@ -799,9 +906,9 @@ def test_existing_members_are_matched_only_once(self): current = ['炽热', '絮弗伦', '岛风', '黑潮', None, None] desired = ['岛风', '黑潮', '阳炎', '早春', '吹雪', '初夏'] shared = ['岛风', '黑潮', '阳炎', '早春', '吹雪', '初夏'] - selectors: list[dict | None] = [ - {'candidates': ['岛风']}, - *[{'candidates': shared} for _ in range(5)], + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('岛风'), + *[_candidate_rule(*shared) for _ in range(5)], ] ok, matched_slots = BattlePreparationPage._match_existing_members( @@ -834,13 +941,38 @@ def test_find_wrong_slots(self): class TestFleetAlignment: + def test_fleet_change_tries_candidates_in_rule_order(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + primary = ShipSelector(name='U-47') + candidate = ShipSelector(name='U-96', relaxed_constraints=True) + + with ( + patch.object(page, 'click_ship_slot'), + patch('autowsgr.ui.utils.wait_for_page'), + patch( + 'autowsgr.ui.choose_ship_page.ChooseShipPage.change_single_ship', + side_effect=[None, 'U-96'], + ) as change_single_ship, + ): + selected = page._change_single_ship( + 0, + 'U-47', + selector=(primary, candidate), + ) + + assert selected == 'U-96' + assert change_single_ship.call_args_list == [ + call(primary, use_search=True), + call(candidate, use_search=True), + ] + def test_slot_failure_does_not_borrow_another_slot_candidates(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) current = [None] * 6 names = ['契卡洛夫', '岛风', None, None, None, None] - selectors: list[dict | None] = [ - {'candidates': ['契卡洛夫'], 'min_level': 100}, - {'candidates': ['岛风', '黑潮'], 'min_level': 100}, + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('契卡洛夫', min_level=100), + _candidate_rule('岛风', '黑潮', min_level=100), None, None, None, @@ -859,9 +991,13 @@ def test_slot_failure_does_not_borrow_another_slot_candidates(self): assert change_ship.call_count == 1 assert change_ship.call_args.args == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector']['options'] == [ - {'name': '契卡洛夫', 'min_level': 100}, - ] + assert change_ship.call_args.kwargs['selector'] == ( + ShipSelector( + name='契卡洛夫', + min_level=100, + relaxed_constraints=True, + ), + ) def test_local_fix_replaces_before_removing(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) diff --git a/testing/ui/test_choose_ship_page.py b/testing/ui/test_choose_ship_page.py index c48d65ed..68476f54 100644 --- a/testing/ui/test_choose_ship_page.py +++ b/testing/ui/test_choose_ship_page.py @@ -1,8 +1,10 @@ """测试选船页的舰名比较逻辑。""" from types import SimpleNamespace -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch +from autowsgr.combat.fleet import ShipSelector +from autowsgr.types import ShipType from autowsgr.ui.choose_ship_page import ChooseShipPage from autowsgr.ui.utils.ship_list import LevelOCRRetryNeededError from autowsgr.vision.ocr import set_ship_name_match_confidence @@ -41,28 +43,16 @@ def test_user_alias_is_used_for_search_and_matching(self): class TestIndependentShipRules: - def test_each_option_uses_its_own_constraints(self): + def test_single_rule_uses_its_own_constraints(self): ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) page = ChooseShipPage(ctx) - selector = { - 'options': [ - { - 'name': 'U-47', - 'search_name': 'U47', - 'ship_type': ['ss', 'ssg'], - 'min_level': 100, - 'max_level': 110, - }, - { - 'name': 'U-96', - 'search_name': 'U96', - 'ship_type': ['ss'], - 'min_level': 90, - 'max_level': 105, - 'relaxed_constraints': True, - }, - ], - } + selector = ShipSelector( + name='U-47', + search_name='U47', + ship_types=(ShipType.SS, ShipType.SSG), + min_level=100, + max_level=110, + ) with ( patch.object(page, 'ensure_search_box'), @@ -71,34 +61,26 @@ def test_each_option_uses_its_own_constraints(self): patch.object( page, '_click_ship_in_list', - side_effect=[None, 'U-96'], + return_value='U-47', ) as click_ship, patch.object(page, '_wait_leave_current_page'), ): - assert page.change_single_ship('U-47', selector=selector) == 'U-96' + assert page.change_single_ship(selector) == 'U-47' - assert input_name.call_args_list == [call('U47'), call('U96')] - assert click_ship.call_args_list == [ - call( - 'U-47', - ship_type=['ss', 'ssg'], - min_level=100, - max_level=110, - relaxed_constraints=False, - ), - call( - 'U-96', - ship_type=['ss'], - min_level=90, - max_level=105, - relaxed_constraints=True, - ), - ] + input_name.assert_called_once_with('U47') + click_ship.assert_called_once_with( + 'U-47', + ship_type=(ShipType.SS, ShipType.SSG), + min_level=100, + max_level=110, + relaxed_constraints=False, + ) def test_multiple_ship_types_are_supported(self): - assert ChooseShipPage._is_ship_type_in_rule('ss', ['ss', 'ssg']) - assert ChooseShipPage._is_ship_type_in_rule('ssg', ['ss_or_ssg']) - assert not ChooseShipPage._is_ship_type_in_rule('bb', ['ss', 'ssg']) + expected = (ShipType.SS, ShipType.SSG) + assert ChooseShipPage._is_ship_type_in_rule(ShipType.SS, expected) + assert ChooseShipPage._is_ship_type_in_rule(ShipType.SSG, expected) + assert not ChooseShipPage._is_ship_type_in_rule(ShipType.BB, expected) def test_primary_rejects_failed_level_constraint(self): ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) @@ -159,13 +141,13 @@ def test_relaxed_candidate_accepts_failed_ship_type_constraint(self): patch.object( page, '_detect_ship_type_near_hit', - return_value='bb', + return_value=ShipType.BB, ) as detect_ship_type, patch('autowsgr.ui.choose_ship_page.time.sleep'), ): matched = page._click_ship_in_list( 'U-96', - ship_type=['ss'], + ship_type=(ShipType.SS,), relaxed_constraints=True, ) From 204fa2beac8ac44df5e0e6f0a45a729735b18c4d Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 4 Aug 2026 14:53:28 +0800 Subject: [PATCH 07/11] feat: improve smart fleet selection and OCR reliability --- autowsgr/combat/fleet.py | 70 +- autowsgr/combat/rules.py | 4 +- autowsgr/constants/__init__.py | 2 + autowsgr/constants/shipnames.py | 30 +- autowsgr/contracts/__init__.py | 1 + autowsgr/contracts/vessel_types.py | 92 ++ .../data/map/decisive_battle/enemy_spec.yaml | 18 +- autowsgr/infra/config_compat.py | 4 +- autowsgr/ops/normal_fight.py | 3 +- autowsgr/server/serializers.py | 23 +- autowsgr/types.py | 12 +- autowsgr/ui/battle/fleet_change/_change.py | 874 ++++++++++++++---- autowsgr/ui/battle/fleet_change/_detect.py | 58 +- autowsgr/ui/choose_ship_page.py | 23 +- autowsgr/ui/decisive/fleet_ocr.py | 1 - autowsgr/vision/ocr.py | 13 +- autowsgr/vision/ocr_rules.py | 29 +- docs/features/ocr-ship-name-reliability.md | 5 +- docs/usage/usage_combat.md | 16 +- pyproject.toml | 2 +- testing/ops/_framework.py | 2 +- testing/ops/normal_fight.py | 1 + testing/ops/test_normal_fight_unit.py | 123 +++ testing/server/test_task_routes.py | 170 +++- testing/test_server_schemas.py | 135 ++- testing/test_vessel_type_contract.py | 28 + testing/ui/battle_preparation/test_unit.py | 646 +++++++++++-- testing/vision/test_ocr.py | 20 + uv.lock | 2 +- 29 files changed, 2023 insertions(+), 384 deletions(-) create mode 100644 autowsgr/contracts/__init__.py create mode 100644 autowsgr/contracts/vessel_types.py create mode 100644 testing/test_vessel_type_contract.py diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py index b177fb09..79921156 100644 --- a/autowsgr/combat/fleet.py +++ b/autowsgr/combat/fleet.py @@ -12,41 +12,63 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any +from autowsgr.contracts.vessel_types import ( + FLEET_VESSEL_TYPE_BY_CODE, + FLEET_VESSEL_TYPES, +) from autowsgr.types import ShipType if TYPE_CHECKING: + from autowsgr_native.vessel_type import VesselType + from autowsgr.combat.plan import CombatPlan -SHIP_TYPE_BY_CODE = MappingProxyType( +NATIVE_FLEET_VESSEL_TYPES = tuple(vessel_type.native for vessel_type in FLEET_VESSEL_TYPES) +"""由公共 native 契约提供的普通舰种。""" + +VESSEL_TYPE_TO_SHIP_TYPE: tuple[tuple[VesselType, ShipType], ...] = tuple( + ( + vessel_type.native, + ShipType[vessel_type.code.upper()], + ) + for vessel_type in FLEET_VESSEL_TYPES +) +"""native 0.3 普通舰种到同名 AutoWSGR 领域枚举的映射。""" + +for _native_type, _ship_type in VESSEL_TYPE_TO_SHIP_TYPE: + if _native_type.as_chinese() != _ship_type.value: + raise RuntimeError( + f'native 舰种中文语义不一致: {_native_type.as_english()}', + ) + + +def ship_type_from_native(vessel_type: VesselType) -> ShipType: + """把 native 普通舰种转换为 AutoWSGR 领域枚举。""" + for native_type, ship_type in VESSEL_TYPE_TO_SHIP_TYPE: + if vessel_type == native_type: + return ship_type + message = f'不支持的 native 舰种: {vessel_type!r}' + raise ValueError(message) + + +NATIVE_VESSEL_TYPE_BY_CODE: Mapping[str, VesselType] = MappingProxyType( + {code: vessel_type.native for code, vessel_type in FLEET_VESSEL_TYPE_BY_CODE.items()}, +) +"""API 使用的 native 0.3 canonical 舰种代码。""" + + +SHIP_TYPE_BY_CODE: Mapping[str, tuple[ShipType, ...]] = MappingProxyType( { - 'dd': (ShipType.DD,), - 'cl': (ShipType.CL,), - 'ca': (ShipType.CA,), - 'cav': (ShipType.CAV,), - 'clt': (ShipType.CLT,), - 'bb': (ShipType.BB,), - 'bc': (ShipType.BC,), - 'bbv': (ShipType.BBV,), - 'cv': (ShipType.CV,), - 'cvl': (ShipType.CVL,), - 'av': (ShipType.AV,), - 'ss': (ShipType.SS,), - 'ssg': (ShipType.SSG,), - 'cg': (ShipType.KP,), - 'cgaa': (ShipType.CG,), - 'ddg': (ShipType.ASDG,), - 'ddgaa': (ShipType.AADG,), - 'bm': (ShipType.BM,), - 'cbg': (ShipType.CBG,), + **{ + code: (ship_type_from_native(vessel_type),) + for code, vessel_type in NATIVE_VESSEL_TYPE_BY_CODE.items() + }, 'ss_or_ssg': (ShipType.SS, ShipType.SSG), - 'ap': (ShipType.NAP,), - 'bbg': (ShipType.BG,), - 'sc': (ShipType.SC,), }, ) -"""API 舰种缩写到后端领域枚举的唯一映射。""" +"""API 舰种代码到后端领域枚举的唯一映射。""" ALLOWED_SHIP_TYPE_CODES = frozenset(SHIP_TYPE_BY_CODE) diff --git a/autowsgr/combat/rules.py b/autowsgr/combat/rules.py index a99b5ea9..5a1ffcba 100644 --- a/autowsgr/combat/rules.py +++ b/autowsgr/combat/rules.py @@ -32,6 +32,7 @@ from enum import Enum, auto from typing import Any +from autowsgr.contracts.vessel_types import FLEET_VESSEL_TYPES from autowsgr.infra.logger import get_logger from autowsgr.types import Formation @@ -40,8 +41,7 @@ _log = get_logger('combat.recognition') _SHIP_TYPE_PATTERN = re.compile( - r'\b(CV|CVL|AV|BB|BBV|BC|CA|CAV|CLT|CL|BM|DD|SSG|SS|SC|NAP|' - r'ASDG|AADG|KP|CG|CBG|BG)\b' + rf'\b({"|".join(re.escape(vessel_type.native.as_english()) for vessel_type in FLEET_VESSEL_TYPES)})\b', ) diff --git a/autowsgr/constants/__init__.py b/autowsgr/constants/__init__.py index bd2ab27f..e64a4042 100644 --- a/autowsgr/constants/__init__.py +++ b/autowsgr/constants/__init__.py @@ -6,6 +6,7 @@ expand_ship_name_candidates, get_ship_name_group_id, get_ship_name_variants, + normalize_ship_name, set_ship_name_aliases, ship_name_identity, update_shipnames, @@ -20,6 +21,7 @@ 'expand_ship_name_candidates', 'get_ship_name_group_id', 'get_ship_name_variants', + 'normalize_ship_name', 'set_ship_name_aliases', 'ship_name_identity', 'update_shipnames', diff --git a/autowsgr/constants/shipnames.py b/autowsgr/constants/shipnames.py index 4610edad..98be8784 100644 --- a/autowsgr/constants/shipnames.py +++ b/autowsgr/constants/shipnames.py @@ -1,9 +1,14 @@ import os +import re from collections.abc import Mapping from autowsgr.infra import load_yaml +SHIP_NAME_SUFFIXES: tuple[str, ...] = ('·改',) +_SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') + + def process_dict(d: dict) -> list[str]: """处理 YAML 数据,提取舰船名称列表。 @@ -86,9 +91,30 @@ def canonical_ship_name(name: str) -> str: return get_ship_name_variants(name)[0] -def ship_name_identity(name: str) -> str: +def normalize_ship_name(value: object) -> str | None: + """统一舰名文本,处理空值、登记别名和明确的显示后缀。""" + if value is None: + return None + + normalized = str(value).strip() + if not normalized: + return None + + normalized = canonical_ship_name(normalized) + for suffix in SHIP_NAME_SUFFIXES: + normalized = normalized.removesuffix(suffix) + normalized = _SHIP_ALIAS_SUFFIX_RE.sub('', normalized).strip() + if not normalized: + return None + return canonical_ship_name(normalized) + + +def ship_name_identity(value: object) -> str | None: """返回用于同船唯一性判断的稳定身份。""" - return get_ship_name_group_id(name) or name + normalized = normalize_ship_name(value) + if normalized is None: + return None + return get_ship_name_group_id(normalized) or normalized def expand_ship_name_candidates(candidates: list[str]) -> list[str]: diff --git a/autowsgr/contracts/__init__.py b/autowsgr/contracts/__init__.py new file mode 100644 index 00000000..f6829d27 --- /dev/null +++ b/autowsgr/contracts/__init__.py @@ -0,0 +1 @@ +"""AutoWSGR 对外公开的数据契约。""" diff --git a/autowsgr/contracts/vessel_types.py b/autowsgr/contracts/vessel_types.py new file mode 100644 index 00000000..86cf2217 --- /dev/null +++ b/autowsgr/contracts/vessel_types.py @@ -0,0 +1,92 @@ +"""由 :mod:`autowsgr_native` 派生的舰队舰种公共契约。""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from types import MappingProxyType +from typing import TYPE_CHECKING + +from autowsgr_native.vessel_type import VesselType + + +if TYPE_CHECKING: + from collections.abc import Mapping + + +CONTRACT_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class FleetVesselType: + """一个可用于舰队规则的 native 舰种。""" + + code: str + label: str + native: VesselType + + +def _discover_fleet_vessel_types() -> tuple[FleetVesselType, ...]: + """发现 native 普通舰种;``NO`` 是唯一的大写特殊类型。""" + vessel_types: list[FleetVesselType] = [] + for attribute in sorted(name for name in dir(VesselType) if name.isupper()): + native = getattr(VesselType, attribute) + code = native.as_english() + if code == 'NO': + continue + if code != attribute or VesselType.from_english(code) != native: + raise RuntimeError(f'autowsgr_native 舰种契约无效: {attribute}') + vessel_types.append( + FleetVesselType( + code=code.lower(), + label=native.as_chinese(), + native=native, + ), + ) + return tuple(vessel_types) + + +FLEET_VESSEL_TYPES = _discover_fleet_vessel_types() +"""当前 native 提供的全部普通舰种。""" + +FLEET_VESSEL_TYPE_BY_CODE: Mapping[str, FleetVesselType] = MappingProxyType( + {vessel_type.code: vessel_type for vessel_type in FLEET_VESSEL_TYPES}, +) +"""小写 canonical code 到 native 舰种契约的只读映射。""" + + +def fleet_vessel_type_from_code(value: str) -> FleetVesselType: + """校验并返回一个 canonical 舰队舰种。""" + code = value.strip().lower() + vessel_type = FLEET_VESSEL_TYPE_BY_CODE.get(code) + if vessel_type is None: + allowed = ', '.join(FLEET_VESSEL_TYPE_BY_CODE) + raise ValueError(f'不支持的舰队舰种: {value!r}, 可选值: {allowed}') + return vessel_type + + +def fleet_vessel_type_contract() -> dict[str, object]: + """返回供 GUI 生成代码使用的稳定 JSON 契约。""" + return { + 'schema_version': CONTRACT_SCHEMA_VERSION, + 'source': 'autowsgr_native.vessel_type.VesselType', + 'ship_types': [ + { + 'code': vessel_type.code, + 'label': vessel_type.label, + } + for vessel_type in FLEET_VESSEL_TYPES + ], + } + + +def main() -> None: + """向标准输出写出 JSON 契约。""" + sys.stdout.write( + f'{json.dumps(fleet_vessel_type_contract(), ensure_ascii=False)}\n', + ) + + +if __name__ == '__main__': + main() diff --git a/autowsgr/data/map/decisive_battle/enemy_spec.yaml b/autowsgr/data/map/decisive_battle/enemy_spec.yaml index d0556daa..4801bc68 100644 --- a/autowsgr/data/map/decisive_battle/enemy_spec.yaml +++ b/autowsgr/data/map/decisive_battle/enemy_spec.yaml @@ -42,13 +42,13 @@ enemy: D: ['', 'BC', 'CVL', 'CVL', 'CL', 'DD'] E: ['', 'BB', 'CA', 'CL', 'CVL', 'CL', 'DD'] F: ['', 'CV', 'BC', 'CL', 'DD', 'DD', 'AADG'] - G: ['', 'BC', 'CBG', 'CA', 'CA', 'CL', 'DD'] + 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', 'CBG', 'CA', 'DD', 'AADG'] + 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'] @@ -57,12 +57,12 @@ 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', 'CBG'] + 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', 'BG', 'CV', 'CA', 'CA', 'CL'] + I: ['', 'BB', 'BBG', 'CV', 'CA', 'CA', 'CL'] J: ['', 'BB', 'CA', 'BBV', 'BBV', 'SS', 'BC'] - @@ -83,14 +83,14 @@ enemy: 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', 'CBG', 'CL', 'CL'] + 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', 'BG', 'BC', 'BC', 'CL', 'CL'] + 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'] @@ -118,18 +118,18 @@ enemy: D: ["", CV, CV, BB, CA, CL, SS] E: ["", BC, BC, CV, CV, CL, CL] F: ["", BB, BB, BB, CA, CA, AADG] - G: ["", CV, BG, CV, CA, CA, CA] + 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, CBG, CBG, CL] + 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, BG] + I: ["", CV, CV, BB, BB, CL, BBG] J: ["", BB, BB, CA, BBV, BBV, BC] diff --git a/autowsgr/infra/config_compat.py b/autowsgr/infra/config_compat.py index 2c971ed6..fb823382 100644 --- a/autowsgr/infra/config_compat.py +++ b/autowsgr/infra/config_compat.py @@ -53,7 +53,7 @@ class LegacyConfigError(Exception): def _is_empty_fleet_slot(value: object) -> bool: """是否是 fleet 的"空槽位" (``None`` / 空串 / 纯空白)。 - 与 :func:`autowsgr.ui.battle.fleet_change._normalize_ship_name` 的 + 与 :func:`autowsgr.constants.normalize_ship_name` 的 空判定一致: 这些值在运行期都会被归一化为 ``None`` (该槽位留空)。 """ if value is None: @@ -259,7 +259,7 @@ def _migrate_plan_fleet(data: dict[str, Any]) -> None: 并触发 ``_reorder`` 的 ``break`` 致验证反复重试 ("卡很多次 fleet 验证")。 本函数剥离所有前导"空槽位", 让经典写法直接生效。 - 中间 / 尾部的 ``""`` 原样保留 —— 运行期 ``_normalize_ship_name`` 会把 + 中间 / 尾部的 ``""`` 原样保留 —— 运行期 ``normalize_ship_name`` 会把 它们归一化为 ``None`` (= 不关心该槽位), 无需在此处理。 """ fleet = data.get('fleet') diff --git a/autowsgr/ops/normal_fight.py b/autowsgr/ops/normal_fight.py index 6dea7a64..09d058c6 100644 --- a/autowsgr/ops/normal_fight.py +++ b/autowsgr/ops/normal_fight.py @@ -355,13 +355,14 @@ def _prepare_for_battle(self) -> list[ShipDamageState]: 'fleet_rules', ) time.sleep(0.5) - resolved_ship_names = page.detect_fleet() + resolved_ship_names = page.last_changed_fleet elif plain_fleet is not None: _require_fleet_change( page.change_fleet(self._fleet_id, exact_fleet_rules(plain_fleet)), 'fleet', ) time.sleep(0.5) + resolved_ship_names = page.last_changed_fleet # 补给 page.apply_supply() diff --git a/autowsgr/server/serializers.py b/autowsgr/server/serializers.py index cc922c76..6488e50b 100644 --- a/autowsgr/server/serializers.py +++ b/autowsgr/server/serializers.py @@ -150,12 +150,25 @@ def build_combat_plan(request: Any) -> Any: from autowsgr.combat.plan import parse_map_value from autowsgr.types import RepairMode - def _build_node_decision(node_req: Any) -> NodeDecision: - return NodeDecision.from_dict( - node_req.model_dump(exclude_none=True), + node_defaults = request.node_defaults.model_dump(exclude_none=True) + + def _build_node_decision( + node_req: Any, + *, + defaults: dict[str, Any] | None = None, + ) -> NodeDecision: + data = {} if defaults is None else dict(defaults) + data.update( + node_req.model_dump( + exclude_none=True, + exclude_unset=defaults is not None, + ), ) + return NodeDecision.from_dict(data) - node_args = {k: _build_node_decision(v) for k, v in request.node_args.items()} + node_args = { + k: _build_node_decision(v, defaults=node_defaults) for k, v in request.node_args.items() + } map_id, entrance = parse_map_value(request.map) return CombatPlan( @@ -169,7 +182,7 @@ def _build_node_decision(node_req: Any) -> NodeDecision: repair_mode=[RepairMode(r) for r in request.repair_mode], fight_condition=request.fight_condition, selected_nodes=request.selected_nodes, - default_node=_build_node_decision(request.node_defaults), + default_node=NodeDecision.from_dict(node_defaults), nodes=node_args, event_name=request.event_name, ) diff --git a/autowsgr/types.py b/autowsgr/types.py index d07fddc7..93227207 100644 --- a/autowsgr/types.py +++ b/autowsgr/types.py @@ -331,13 +331,13 @@ class ShipType(StrEnum): SSG = '导潜' SS = '潜艇' SC = '炮潜' - NAP = '补给' + AP = '补给' ASDG = '导驱' AADG = '防驱' KP = '导巡' CG = '防巡' - CBG = '大巡' - BG = '导战' + BG = '大巡' + BBG = '导战' Other = '其他' @property @@ -359,13 +359,13 @@ def relative_position_in_destroy(self) -> tuple[float, float]: ShipType.SSG: (0.738, 0.379), ShipType.SS: (0.830, 0.379), ShipType.SC: (0.922, 0.379), - ShipType.NAP: (0.555, 0.470), + ShipType.AP: (0.555, 0.470), ShipType.ASDG: (0.646, 0.470), ShipType.AADG: (0.738, 0.470), ShipType.KP: (0.830, 0.470), ShipType.CG: (0.922, 0.470), - ShipType.CBG: (0.555, 0.561), - ShipType.BG: (0.646, 0.561), + ShipType.BG: (0.555, 0.561), + ShipType.BBG: (0.646, 0.561), ShipType.Other: (0.738, 0.561), } return _map[self] diff --git a/autowsgr/ui/battle/fleet_change/_change.py b/autowsgr/ui/battle/fleet_change/_change.py index 154aa24c..f2014b79 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -1,37 +1,41 @@ """智能换船算法。 -1. 读取 YAML 传入的前六个舰队槽位。 -2. 整理每个槽位的优选、备选和筛选条件。 -3. 使用回溯算法为六个槽位分配不同舰名。 -4. OCR 识别当前舰队,已经正确时直接结束。 -5. 首次调整时保留可复用舰船并补齐缺少舰船。 -6. 先替换目标舰船,再删除多余舰船,避免一队为空。 -7. 删除舰船造成槽位压缩后,再检查并补齐缺员。 -8. 拖拽舰船,将现有成员调整到目标槽位。 -9. OCR 再次验证舰名、顺序和空槽。 -10. 验证失败后只修正错误槽位,最多修正两次。 +1. 保留所有可用主选,并为 candidate-only 槽位分配唯一备选。 +2. 使用全部主选和备选作为全局 OCR 补救上下文。 +3. 结合血条探针区分空槽和有舰船但舰名未识别的槽位。 +4. OCR 当前舰队,candidate-only 优先复用未被主选占用的已有舰船。 +5. 保留已有目标成员,优先补齐主选,再处理 fallback 和 candidate-only。 +6. 主选失败后重新执行全局唯一分配,不能局部抢占其他主选。 +7. 先替换目标舰船,再删除多余舰船,避免一队为空。 +8. 删除舰船造成槽位压缩后,再检查并补齐缺员。 +9. 成员集合完整后拖拽舰船,将现有成员调整到目标槽位。 +10. OCR 再次验证舰名、顺序、空槽和同舰唯一性。 +11. 验证失败后只修正错误槽位,最多修正两次。 一个 YAML 只执行一套舰队,不会切换其他 preset。 常规出征使用搜索框,决战可通过开关选择是否使用本算法。 """ from __future__ import annotations -import re import time +from dataclasses import dataclass +from functools import cache from typing import TYPE_CHECKING from autowsgr.combat.fleet import FleetSlotRule, ShipSelector -from autowsgr.constants import ship_name_identity +from autowsgr.constants import normalize_ship_name, ship_name_identity from autowsgr.infra.logger import get_logger -from autowsgr.ui.battle.constants import CLICK_SHIP_SLOT +from autowsgr.ui.battle.constants import CLICK_BACK, CLICK_SHIP_SLOT -from ._detect import FleetDetectMixin +from ._detect import FleetDetectMixin, FleetSnapshot # 仅在类型检查时导入 Sequence,运行时不产生额外依赖。 if TYPE_CHECKING: from collections.abc import Sequence + from autowsgr.ui.choose_ship_page import ChooseShipPage + # 记录智能换船过程中的关键步骤和失败原因。 _log = get_logger('ui.preparation') @@ -42,8 +46,13 @@ # 等待选船页面出现的超时 (秒) _CHOOSE_PAGE_TIMEOUT: float = 5.0 -# 舰名尾部别名后缀,如“(苍青幻影)” -_SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') + +@dataclass(frozen=True, slots=True) +class _ShipSelection: + """选船页实际命中的舰名和精确规则。""" + + name: str | None + option: ShipSelector | None # 为普通出征和决战准备页提供同一套智能换船流程。 @@ -52,6 +61,14 @@ class FleetChangeMixin(FleetDetectMixin): # True 使用搜索框选船,False 直接通过 OCR 列表选船。 _use_search: bool = True + _last_changed_fleet: list[str | None] | None = None + + @property + def last_changed_fleet(self) -> list[str | None] | None: + """返回最近一次换船成功时已验证的实际舰队。""" + if self._last_changed_fleet is None: + return None + return list(self._last_changed_fleet) # 执行一套六槽舰队的完整换船、排序和验证流程。 def change_fleet( @@ -60,62 +77,107 @@ def change_fleet( ship_names: Sequence[FleetSlotRule], ) -> bool: """返回最终舰队是否符合六个目标槽位。""" + self._last_changed_fleet = None # Step 1:切换到 YAML 指定的舰队。 # 当前舰队已经正确时,不重复点击舰队按钮。 if fleet_id and self.get_selected_fleet(self._ctrl.screenshot()) != fleet_id: self.select_fleet(fleet_id) time.sleep(0.5) - # Step 2:分别保存六个槽位的目标舰名和选船规则。 - names: list[str | None] = [] - selectors: list[FleetSlotRule | None] = [] - for slot_rule in list(ship_names)[:6]: - selectors.append(slot_rule) - names.append(self._normalize_ship_name(slot_rule.preferred_name)) - - # Step 3:不足六槽时补空,并为所有槽位分配互不重复的舰名。 - names += [None] * (6 - len(names)) + # Step 2:保存六个槽位的规则,不足六槽时补空。 + selectors: list[FleetSlotRule | None] = list(ship_names[:6]) selectors += [None] * (6 - len(selectors)) - # unique_names 是处理候选冲突后的最终目标舰名。 - unique_names = self._assign_unique_targets(names, selectors) - # 无法找到不重名组合时,停止换船,避免组成非法舰队。 - if unique_names is None: - _log.error('[准备页] 目标编成无法满足同名舰唯一约束: {}', names) + + # Step 3:主选全部保留,candidate-only 通过全局回溯分配唯一备选。 + assigned = self._plan_target_options(selectors) + if assigned is None: + _log.error('[准备页] 目标编成无法满足主选和同舰唯一约束') return False - names = unique_names # 第一舰队最后一艘舰船不能移除,但第一舰队本身允许更换编成。 - if fleet_id == 1 and names[0] is None: + if fleet_id == 1 and assigned[0] is None: raise ValueError('1 队槽位 0 不能为空') - _log.info('[准备页] 目标编成: {}', names) + + expected_pool = self._ocr_target_pool(selectors) + snapshot = self._detect_initial_snapshot(expected_pool) + current = snapshot.names + occupied = snapshot.occupied + assigned = self._plan_target_options( + selectors, + current, + ) + if assigned is None: + _log.error('[准备页] 当前舰队无法分配为主选优先的不重名编成') + return False + _log.info( + '[准备页] 根据主选优先规则确定目标编成: {}', + self._target_names(assigned), + ) # Step 4:首次完整调整,后续最多进行两次局部修正。 + # verified_slots 记录本轮已通过选船页校验舰种和等级的逻辑目标槽位。 + verified_slots: set[int] = set() + unavailable: set[tuple[int, ShipSelector]] = set() + locked: dict[int, ShipSelector] = {} for attempt in range(_MAX_SET_RETRIES + 1): - # current 保存本轮开始时 OCR 识别到的六个槽位。 - current = self.detect_fleet() - + names = self._target_names(assigned) # 当前舰队已经满足目标时,直接结束本次换船。 - if self._validate_with_selector(current, names, selectors): + if self._validate_assignment( + current, + occupied, + assigned, + verified_slots, + ): _log.info('[准备页] 舰队已满足目标, 跳过换船') + self._last_changed_fleet = list(current) return True # Step 5:第一轮执行完整对齐,重试轮只处理错误槽位。 # 第一次调整需要补船、删船并处理槽位压缩。 if attempt == 0: - self._full_align(current, names, selectors) + self._full_align( + current, + occupied, + assigned, + selectors, + verified_slots, + unavailable, + locked, + expected_pool, + ) # 后续调整只修正 OCR 验证失败的槽位。 else: _log.info('[准备页] 第 {} 次重试: 局部修正', attempt) - self._local_fix(current, names, selectors) + self._local_fix( + current, + occupied, + assigned, + selectors, + verified_slots, + unavailable, + locked, + expected_pool, + ) # Step 6:重新识别成员,再通过拖拽调整舰船顺序。 - current = self.detect_fleet(expected_names=names) + names = self._target_names(assigned) + snapshot = self.detect_fleet_snapshot(expected_pool=expected_pool) + current = snapshot.names + occupied = snapshot.occupied self._reorder(current, names) # Step 7:最终 OCR 验证舰名、顺序、空槽和重名情况。 - current = self.detect_fleet(expected_names=names) + snapshot = self.detect_fleet_snapshot(expected_names=names) + current = snapshot.names + occupied = snapshot.occupied # 最终舰队符合目标时,返回成功。 - if self._validate_with_selector(current, names, selectors): + if self._validate_assignment( + current, + occupied, + assigned, + verified_slots, + ): _log.info('[准备页] 编成更换完成: {}', current) + self._last_changed_fleet = list(current) return True # 仍有重试次数时,等待页面稳定后进入下一轮局部修正。 @@ -126,6 +188,9 @@ def change_fleet( _MAX_SET_RETRIES + 1, ) time.sleep(0.5) + snapshot = self.detect_fleet_snapshot(expected_names=names) + current = snapshot.names + occupied = snapshot.occupied # 所有重试都失败时,记录当前舰队并退出。 else: @@ -137,24 +202,182 @@ def change_fleet( return False - # 清理 OCR、YAML 和选船结果中的明确后缀,保留用户自定义舰名。 - @staticmethod - def _normalize_ship_name(value: object) -> str | None: - if value is None: - return None + @classmethod + def _plan_target_options( + cls, + selectors: list[FleetSlotRule | None], + current: Sequence[str | None] = (), + unavailable: ( + set[tuple[int, ShipSelector]] | frozenset[tuple[int, ShipSelector]] + ) = frozenset(), + locked: dict[int, ShipSelector] | None = None, + ) -> list[ShipSelector | None] | None: + """按主选优先级规划全局唯一的精确选船规则。""" + locked = locked or {} + current_identities = { + identity for name in current if (identity := ship_name_identity(name)) is not None + } + slot_options: list[tuple[ShipSelector | None, ...]] = [] + + for slot, selector in enumerate(selectors): + if selector is None: + if slot in locked: + return None + slot_options.append((None,)) + continue + + locked_option = locked.get(slot) + if locked_option is not None: + if locked_option not in selector.options or (slot, locked_option) in unavailable: + return None + slot_options.append((locked_option,)) + continue - # normalized 依次去掉空格、“·改”和尾部括号别名。 - normalized = str(value).strip() - normalized = normalized.removesuffix('·改') - normalized = _SHIP_ALIAS_SUFFIX_RE.sub('', normalized) - normalized = normalized.strip() - return normalized or None + if selector.primary is not None and (slot, selector.primary) not in unavailable: + slot_options.append((selector.primary,)) + continue + + ranked = [ + (index, option) + for index, option in enumerate(selector.candidates) + if (slot, option) not in unavailable + ] + ranked.sort( + key=lambda item: ( + ship_name_identity(item[1].name) not in current_identities, + item[0], + ), + ) + slot_options.append(tuple(option for _, option in ranked)) + + @cache + def assign( + slot: int, + used: tuple[str, ...], + ) -> tuple[int, tuple[int, ...], tuple[ShipSelector | None, ...]] | None: + if slot >= len(slot_options): + return 0, (), () + + best: tuple[int, tuple[int, ...], tuple[ShipSelector | None, ...]] | None = None + used_set = set(used) + for rank, option in enumerate(slot_options[slot]): + if option is None: + result = assign(slot + 1, used) + identity = None + else: + identity = ship_name_identity(option.name) + if identity is None or identity in used_set: + continue + result = assign(slot + 1, tuple(sorted((*used, identity)))) + if result is None: + continue + + rest_cost, rest_priority, rest_assignment = result + replacement_cost = 0 if option is None or identity in current_identities else 1 + candidate = ( + replacement_cost + rest_cost, + (rank, *rest_priority), + (option, *rest_assignment), + ) + if best is None or candidate[:2] < best[:2]: + best = candidate + return best + + result = assign(0, ()) + return list(result[2]) if result is not None else None - # 将同一 No.xxx 舰船组中的标准名和用户自定义名统一为同一身份。 @classmethod - def _ship_identity(cls, value: object) -> str | None: - normalized = cls._normalize_ship_name(value) - return ship_name_identity(normalized) if normalized is not None else None + def _ocr_target_pool( + cls, + selectors: Sequence[FleetSlotRule | None], + ) -> list[str]: + """返回全部主选和备选组成的位置无关 OCR 上下文池。""" + pool: list[str] = [] + seen: set[str] = set() + for selector in selectors: + if selector is None: + continue + for option in selector.options: + normalized = normalize_ship_name(option.name) + identity = ship_name_identity(normalized) + if normalized is not None and identity is not None and identity not in seen: + pool.append(normalized) + seen.add(identity) + return pool + + @classmethod + def _target_names( + cls, + assigned: Sequence[ShipSelector | None], + ) -> list[str | None]: + """把精确规则转换为最终逐槽 OCR 使用的标准舰名。""" + return [ + normalize_ship_name(option.name) if option is not None else None for option in assigned + ] + + def _detect_initial_snapshot(self, expected_pool: Sequence[str]) -> FleetSnapshot: + """初次识别舰队;存在未知占用槽位时再识别一次并保守合并。""" + first = self.detect_fleet_snapshot(expected_pool=expected_pool) + if not first.unknown_slots: + return first + + second = self.detect_fleet_snapshot(expected_pool=expected_pool) + names = list(first.names) + for slot, second_name in enumerate(second.names): + if names[slot] is None and second_name is not None: + names[slot] = second_name + elif ( + names[slot] is not None + and second_name is not None + and ship_name_identity(names[slot]) != ship_name_identity(second_name) + ): + names[slot] = None + occupied = [ + first_occupied or second_occupied + for first_occupied, second_occupied in zip( + first.occupied, + second.occupied, + strict=True, + ) + ] + return FleetSnapshot(names=names, occupied=occupied) + + @classmethod + def _option_matches_name( + cls, + current_name: str | None, + option: ShipSelector, + ) -> bool: + """判断准备页舰名是否与一条精确规则属于同一舰船身份。""" + return ship_name_identity(current_name) == ship_name_identity( + option.name + ) and cls._matches_search_name(current_name, option.search_name) + + @classmethod + def _validate_assignment( + cls, + current: Sequence[str | None], + occupied: Sequence[bool], + assigned: Sequence[ShipSelector | None], + verified_slots: set[int] | frozenset[int] = frozenset(), + ) -> bool: + """验证舰名、占用、位置、唯一性和 strict 选船记录。""" + identities = [ + identity for name in current if (identity := ship_name_identity(name)) is not None + ] + if len(identities) != len(set(identities)): + return False + + for slot, option in enumerate(assigned): + if option is None: + if occupied[slot] or current[slot] is not None: + return False + continue + if not occupied[slot] or not cls._option_matches_name(current[slot], option): + return False + if cls._requires_selection_validation(option) and slot not in verified_slots: + return False + return True # 按“已分配舰名优先、其余规则随后”的顺序生成本槽完整规则。 @classmethod @@ -163,14 +386,14 @@ def _slot_options( name: str | None, selector: FleetSlotRule | None, ) -> list[ShipSelector]: - normalized_name = cls._normalize_ship_name(name) + normalized_name = normalize_ship_name(name) if selector is None: return [ShipSelector(name=normalized_name)] if normalized_name else [] options = list(selector.options) - target_identity = cls._ship_identity(normalized_name) + target_identity = ship_name_identity(normalized_name) options.sort( - key=lambda option: cls._ship_identity(option.name) != target_identity, + key=lambda option: ship_name_identity(option.name) != target_identity, ) return options @@ -184,13 +407,48 @@ def _slot_candidates( candidates: list[str] = [] seen: set[str] = set() for option in cls._slot_options(name, selector): - normalized = cls._normalize_ship_name(option.name) - identity = cls._ship_identity(normalized) + normalized = normalize_ship_name(option.name) + identity = ship_name_identity(normalized) if normalized is not None and identity is not None and identity not in seen: candidates.append(normalized) seen.add(identity) return candidates + @classmethod + def _prefer_existing_targets( + cls, + names: list[str | None], + selectors: list[FleetSlotRule | None], + current: list[str | None], + ) -> list[str | None]: + """从每个槽位的候选集合中优先选择当前舰队已有成员。""" + preferred = list(names) + reused: set[str] = set() + + for slot, selector in enumerate(selectors): + if selector is None or names[slot] is None: + continue + + for option in selector.options: + identity = ship_name_identity(option.name) + if identity is None or identity in reused: + continue + # strict 舰种/等级条件不能仅凭准备页舰名 OCR 判定满足。 + if cls._requires_selection_validation(option): + continue + if not any( + ship_name_identity(ship) == identity + and cls._matches_search_name(ship, option.search_name) + for ship in current + ): + continue + + preferred[slot] = normalize_ship_name(option.name) + reused.add(identity) + break + + return preferred + # 为六个槽位挑选互不重复的目标舰名,冲突时自动尝试备选。 @classmethod def _assign_unique_targets( @@ -214,7 +472,7 @@ def assign(slot: int, used: set[str]) -> bool: if names[slot] is None: return assign(slot + 1, used) for candidate in options[slot]: - identity = cls._ship_identity(candidate) + identity = ship_name_identity(candidate) if identity is None or identity in used: continue assigned[slot] = candidate @@ -242,7 +500,7 @@ def _matches_search_name(cls, current_name: str | None, raw_search_name: str | N if current_name == search_name: return True - return cls._ship_identity(current_name) == cls._ship_identity(search_name) + return ship_name_identity(current_name) == ship_name_identity(search_name) @classmethod def _option_for_name( @@ -251,16 +509,25 @@ def _option_for_name( selector: FleetSlotRule | None, ) -> ShipSelector | None: """返回与实际舰名对应的独立规则。""" - identity = cls._ship_identity(name) + identity = ship_name_identity(name) return next( ( option for option in cls._slot_options(name, selector) - if cls._ship_identity(option.name) == identity + if ship_name_identity(option.name) == identity ), None, ) + @staticmethod + def _requires_selection_validation(option: ShipSelector | None) -> bool: + """返回规则是否必须通过选船页校验舰种或等级。""" + return bool( + option is not None + and not option.relaxed_constraints + and (option.ship_types or option.min_level is not None or option.max_level is not None) + ) + # 从本槽候选中排除队内同名舰,并返回实际可用于选船的规则。 @classmethod def _select_available_candidate( @@ -280,19 +547,19 @@ def _select_available_candidate( options = cls._slot_options(name, selector) # occupied 保存队内其他槽位已经占用的舰船组身份。 occupied = { - cls._ship_identity(ship) + ship_name_identity(ship) for idx, ship in enumerate(current) if ship is not None and idx != slot_to_replace } # available 保留当前舰队中尚未占用的完整规则。 available = [ - option for option in options if cls._ship_identity(option.name) not in occupied + option for option in options if ship_name_identity(option.name) not in occupied ] if len(available) == 0: return None, None - chosen = cls._normalize_ship_name(available[0].name) + chosen = normalize_ship_name(available[0].name) # 选船页面按顺序尝试未占用规则,各备选使用自己的约束。 return chosen, tuple(available) @@ -303,6 +570,7 @@ def _match_existing_members( current: list[str | None], desired: list[str | None], selectors: list[FleetSlotRule | None], + verified_slots: set[int] | frozenset[int] = frozenset(), ) -> tuple[list[bool], set[int]]: """在当前舰队与目标槽位之间做一对一匹配。 @@ -323,8 +591,10 @@ def _match_existing_members( def matches(slot: int, ship: str | None) -> bool: selector = selectors[slot] option = cls._option_for_name(desired[slot], selector) - return cls._ship_identity(ship) == cls._ship_identity(desired[slot]) and ( - option is None or cls._matches_search_name(ship, option.search_name) + return ( + ship_name_identity(ship) == ship_name_identity(desired[slot]) + and (option is None or cls._matches_search_name(ship, option.search_name)) + and (not cls._requires_selection_validation(option) or slot in verified_slots) ) # 第一轮优先保留已经位于正确槽位的舰船。 @@ -360,15 +630,19 @@ def _slot_matches( current_name: str | None, target: str | None, selector: FleetSlotRule | None, + *, + selection_verified: bool = False, ) -> bool: # 目标为空时,只有当前槽也为空才算匹配。 if target is None: return current_name is None if selector is None: - return cls._ship_identity(current_name) == cls._ship_identity(target) + return ship_name_identity(current_name) == ship_name_identity(target) option = cls._option_for_name(current_name, selector) if option is None: return False + if cls._requires_selection_validation(option) and not selection_verified: + return False return cls._matches_search_name( current_name, option.search_name, @@ -381,12 +655,21 @@ def _validate_with_selector( current: list[str | None], desired: list[str | None], selectors: list[FleetSlotRule | None], + verified_slots: set[int] | frozenset[int] = frozenset(), ) -> bool: - members = [cls._ship_identity(name) for name in current if name is not None] + members = [ship_name_identity(name) for name in current if name is not None] if len(members) != len(set(members)): return False - return all(cls._slot_matches(current[i], desired[i], selectors[i]) for i in range(6)) + return all( + cls._slot_matches( + current[i], + desired[i], + selectors[i], + selection_verified=i in verified_slots, + ) + for i in range(6) + ) # 找出当前舰队中需要替换、补充或移除的槽位。 @classmethod @@ -395,134 +678,300 @@ def _find_wrong_slots( current: list[str | None], names: list[str | None], selectors: list[FleetSlotRule | None], + verified_slots: set[int] | frozenset[int] = frozenset(), ) -> list[int]: """返回所有不符合目标规则的槽位下标。""" - return [i for i in range(6) if not cls._slot_matches(current[i], names[i], selectors[i])] + return [ + i + for i in range(6) + if not cls._slot_matches( + current[i], + names[i], + selectors[i], + selection_verified=i in verified_slots, + ) + ] + + @classmethod + def _assignment_locations( + cls, + current: Sequence[str | None], + occupied: Sequence[bool], + assigned: Sequence[ShipSelector | None], + verified_slots: set[int] | frozenset[int], + ) -> tuple[set[int], set[int], dict[int, int]]: + """定位当前成员对应的逻辑目标,并标记已满足目标。""" + protected: set[int] = set() + satisfied: set[int] = set() + target_positions: dict[int, int] = {} + + for target_slot, option in enumerate(assigned): + if option is None: + continue + positions = [target_slot, *[slot for slot in range(6) if slot != target_slot]] + position = next( + ( + slot + for slot in positions + if slot not in protected + and occupied[slot] + and cls._option_matches_name(current[slot], option) + ), + None, + ) + if position is None: + continue + protected.add(position) + target_positions[target_slot] = position + if not cls._requires_selection_validation(option) or target_slot in verified_slots: + satisfied.add(target_slot) + + return protected, satisfied, target_positions + + @classmethod + def _target_order( + cls, + assigned: Sequence[ShipSelector | None], + selectors: Sequence[FleetSlotRule | None], + ) -> list[int]: + """主选目标优先,其余目标按逻辑槽位顺序处理。""" + slots = [slot for slot, option in enumerate(assigned) if option is not None] + return sorted( + slots, + key=lambda slot: ( + selectors[slot] is None + or selectors[slot].primary is None + or assigned[slot] != selectors[slot].primary, + slot, + ), + ) + + @classmethod + def _replacement_slot( + cls, + current: Sequence[str | None], + occupied: Sequence[bool], + option: ShipSelector, + protected: set[int], + target_position: int | None, + attempted: set[tuple[int, ShipSelector, int]], + target_slot: int, + ) -> int | None: + """选择补船位置:原舰、空槽、多余舰、未知占用。""" + if target_position is not None: + key = (target_slot, option, target_position) + return target_position if key not in attempted else None + + empty_slots = [slot for slot in range(6) if slot not in protected and not occupied[slot]] + extra_slots = [ + slot + for slot in range(6) + if slot not in protected and occupied[slot] and current[slot] is not None + ] + normal_slots = [*empty_slots, *extra_slots] + if normal_slots and not any( + (target_slot, option, slot) in attempted for slot in normal_slots + ): + return normal_slots[0] - # 为一个目标槽位选择舰船,并同步更新当前舰队和目标舰名。 - def _replace_target( + return next( + ( + slot + for slot in range(6) + if slot not in protected + and occupied[slot] + and current[slot] is None + and (target_slot, option, slot) not in attempted + ), + None, + ) + + def _align_member_set( self, current: list[str | None], - names: list[str | None], + occupied: list[bool], + assigned: list[ShipSelector | None], selectors: list[FleetSlotRule | None], - target_slot: int, - ship_slot: int | None = None, + verified_slots: set[int], + unavailable: set[tuple[int, ShipSelector]], + locked: dict[int, ShipSelector], + ) -> None: + """只处理成员集合;不拖拽最终顺序,也不删除多余舰船。""" + attempted: set[tuple[int, ShipSelector, int]] = set() + for _ in range(48): + protected, satisfied, target_positions = self._assignment_locations( + current, + occupied, + assigned, + verified_slots, + ) + missing = [ + slot for slot in self._target_order(assigned, selectors) if slot not in satisfied + ] + if not missing: + return + + target_slot = missing[0] + option = assigned[target_slot] + assert option is not None + ship_slot = self._replacement_slot( + current, + occupied, + option, + protected, + target_positions.get(target_slot), + attempted, + target_slot, + ) + if ship_slot is None: + _log.warning( + "[准备页] 目标槽位 {} 的规则 '{}' 不可用,重新规划备选", + target_slot, + option.name, + ) + unavailable.add((target_slot, option)) + locked.pop(target_slot, None) + verified_slots.discard(target_slot) + previous = list(assigned) + replanned = self._plan_target_options( + selectors, + current, + unavailable, + locked, + ) + if replanned is None: + raise RuntimeError( + f'目标槽位 {target_slot} 的主选和备选均不可用', + ) + assigned[:] = replanned + for slot, (old, new) in enumerate( + zip(previous, replanned, strict=True), + ): + if old != new: + verified_slots.discard(slot) + continue + + _log.info( + "[准备页] 更换物理槽位 {} <- '{}' (逻辑槽位 {}, 原: '{}')", + ship_slot, + option.name, + target_slot, + current[ship_slot], + ) + selection = self._try_select_option( + ship_slot, + option, + ) + attempted.add((target_slot, option, ship_slot)) + if selection.name is None: + continue + if not self._option_matches_name(selection.name, option): + raise RuntimeError( + f'选船结果 {selection.name!r} 与规则 {option.name!r} 不一致', + ) + + current[ship_slot] = selection.name + occupied[ship_slot] = True + locked[target_slot] = option + verified_slots.discard(target_slot) + if self._requires_selection_validation(option): + verified_slots.add(target_slot) + time.sleep(0.3) + + raise RuntimeError('成员集合调整次数超过安全上限') + + def _remove_extra_members( + self, + current: list[str | None], + occupied: list[bool], + assigned: Sequence[ShipSelector | None], + verified_slots: set[int], ) -> None: - """选择目标舰船,并更新当前舰队和目标舰名。""" - target = names[target_slot] - assert target is not None - slot = target_slot if ship_slot is None else ship_slot - selected_name, selected_selector = self._select_available_candidate( + """目标成员齐全后,从后往前删除所有多余或未知成员。""" + protected, _, _ = self._assignment_locations( current, - target, - selectors[target_slot], - slot_to_replace=slot, + occupied, + assigned, + verified_slots, ) - # 本槽所有候选都被占用时,无法组成目标舰队。 - if selected_name is None: - raise RuntimeError(f'目标槽位 {target_slot} 没有未被占用的候选舰船') + for slot in range(5, -1, -1): + if slot in protected or not occupied[slot]: + continue + _log.info("[准备页] 移除多余槽位 {} 的 '{}'", slot, current[slot]) + self._change_single_ship(slot, None, slot_occupied=True) + current[slot] = None + occupied[slot] = False + time.sleep(0.3) - _log.info( - "[准备页] 更换槽位 {} <- '{}' (原: '{}')", - slot, - selected_name, - current[slot], - ) - selected = self._change_single_ship( - slot, - selected_name, - selector=selected_selector, - slot_occupied=current[slot] is not None, - ) - actual = selected if selected is not None else selected_name - current[slot] = actual - names[target_slot] = actual - time.sleep(0.3) + def _refresh_members( + self, + current: list[str | None], + occupied: list[bool], + expected_pool: Sequence[str], + ) -> None: + """删除或替换后重新获取成员集合和占用状态。""" + snapshot = self.detect_fleet_snapshot(expected_pool=expected_pool) + current[:] = snapshot.names + occupied[:] = snapshot.occupied # 首次调整时完成成员复用、缺员补充、多余成员移除和压缩后补位。 def _full_align( self, current: list[str | None], - names: list[str | None], + occupied: list[bool], + assigned: list[ShipSelector | None], selectors: list[FleetSlotRule | None], + verified_slots: set[int], + unavailable: set[tuple[int, ShipSelector]], + locked: dict[int, ShipSelector], + expected_pool: Sequence[str], ) -> None: - """首次将当前成员调整成目标成员集合。""" - # ok 标记当前可保留位置,matched_slots 标记已满足的目标槽位。 - ok, matched_slots = self._match_existing_members(current, names, selectors) - - # Step 1:把尚未满足的目标舰船放入可替换槽位。 - for i, name in enumerate(names): - if name is None: - continue - if i in matched_slots: - continue - # slot 是当前舰队中第一个不能保留、可以用于替换的位置。 - slot = next((idx for idx in range(6) if not ok[idx]), None) - if slot is None: - raise RuntimeError(f"无可用槽位放置目标舰船 '{name}'") - self._replace_target(current, names, selectors, i, slot) - ok[slot] = True - matched_slots.add(i) - - # Step 2:从后往前移除剩余多余舰船,减少槽位压缩影响。 - for i in range(5, -1, -1): - # 当前位置不能保留且仍有舰船时,将该舰船移除。 - if not ok[i] and current[i] is not None: - _log.info("[准备页] 移除槽位 {} 的 '{}'", i, current[i]) - self._change_single_ship(i, None, slot_occupied=True) - current[i] = None - time.sleep(0.3) - - # Step 3:重新 OCR,检查删除舰船造成的槽位压缩和缺员。 - current[:] = self.detect_fleet(expected_names=names) - target_count = sum(1 for v in names if v is not None) - current_count = sum(1 for v in current if v is not None) - # 实际舰船少于目标数量时,逐槽补齐缺少成员。 - if current_count < target_count: - for i, name in enumerate(names): - if name is None: - continue - if current[i] is not None: - continue - self._replace_target(current, names, selectors, i) - - current_count = sum(1 for v in current if v is not None) - if current_count >= target_count: - break + """首次将当前舰队调整为目标成员集合。""" + self._align_member_set( + current, + occupied, + assigned, + selectors, + verified_slots, + unavailable, + locked, + ) + self._remove_extra_members(current, occupied, assigned, verified_slots) + self._refresh_members(current, occupied, expected_pool) + self._align_member_set( + current, + occupied, + assigned, + selectors, + verified_slots, + unavailable, + locked, + ) - # OCR 验证失败后,只替换或移除不符合目标的槽位。 + # OCR 验证失败后,只修正成员集合,不在此阶段拖拽排序。 def _local_fix( self, current: list[str | None], - names: list[str | None], + occupied: list[bool], + assigned: list[ShipSelector | None], selectors: list[FleetSlotRule | None], + verified_slots: set[int], + unavailable: set[tuple[int, ShipSelector]], + locked: dict[int, ShipSelector], + expected_pool: Sequence[str], ) -> None: - """只修正本轮识别出的错误槽位。""" - # wrong 保存所有需要替换、补充或移除的槽位。 - wrong = self._find_wrong_slots(current, names, selectors) - if not wrong: - return - - _log.info('[准备页] 局部修正: 错误槽位 {}', wrong) - - # 先完成替换/补员,再移除多余舰船。1 队只剩最后一艘时, - # 这能保证槽位 0 直接替换,不会先进入空队状态。 - replacement_slots = [i for i in wrong if names[i] is not None] - removal_slots = [i for i in wrong if names[i] is None] - - # Step 1:先替换和补船,避免一队在移除时变成空队。 - for i in replacement_slots: - self._replace_target(current, names, selectors, i) - - # Step 2:再从后往前移除目标为空的多余舰船。 - for i in reversed(removal_slots): - # 当前槽位已经为空时,不重复进入选船页面。 - if current[i] is None: - continue - _log.info("[准备页] 局部修正: 移除槽位 {} 的 '{}'", i, current[i]) - self._change_single_ship(i, None, slot_occupied=True) - current[i] = None - time.sleep(0.3) + """重试时重新补齐成员,再清理多余成员。""" + self._align_member_set( + current, + occupied, + assigned, + selectors, + verified_slots, + unavailable, + locked, + ) + self._remove_extra_members(current, occupied, assigned, verified_slots) + self._refresh_members(current, occupied, expected_pool) # 从左到右拖拽舰船,使当前舰队顺序与目标槽位一致。 def _reorder( @@ -535,14 +984,14 @@ def _reorder( target = desired[i] if target is None: break - target_identity = self._ship_identity(target) - if self._ship_identity(current[i]) == target_identity: + target_identity = ship_name_identity(target) + if ship_name_identity(current[i]) == target_identity: continue try: src = next( idx for idx, current_name in enumerate(current) - if self._ship_identity(current_name) == target_identity + if ship_name_identity(current_name) == target_identity ) # 当前舰队中找不到目标舰船时,保留现场交给最终验证处理。 except StopIteration: @@ -580,6 +1029,52 @@ def _circular_move( current.insert(dst, ship) time.sleep(0.5) + def _open_choose_page(self, slot: int) -> ChooseShipPage: + """打开指定物理槽位的选船页面。""" + from autowsgr.ui.choose_ship_page import ChooseShipPage + from autowsgr.ui.utils import wait_for_page + + self.click_ship_slot(slot) + wait_for_page( + self._ctrl, + ChooseShipPage.is_current_page, + timeout=_CHOOSE_PAGE_TIMEOUT, + source='编队', + target='编队选船', + ) + return ChooseShipPage(self._ctx) + + def _cancel_choose_page(self) -> None: + """规则未命中时退出选船页,恢复到编队准备页。""" + from autowsgr.ui.utils import wait_for_page + + self._ctrl.click(*CLICK_BACK) + wait_for_page( + self._ctrl, + self.is_current_page, + timeout=_CHOOSE_PAGE_TIMEOUT, + source='编队选船', + target='编队', + ) + + def _try_select_option( + self, + slot: int, + option: ShipSelector, + ) -> _ShipSelection: + """尝试一条精确规则;未命中返回 None,技术异常直接上抛。""" + if self._ctx.ocr is None: + raise RuntimeError('智能换船需要 OCR 引擎') + + 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=selected, option=option) + # 打开指定槽位的选船页面,完成单艘舰船的选择或移除。 def _change_single_ship( self, @@ -590,24 +1085,12 @@ def _change_single_ship( slot_occupied: bool = True, ) -> str | None: """返回选船页面实际选中的舰名。""" - from autowsgr.ui.choose_ship_page import ChooseShipPage - from autowsgr.ui.utils import wait_for_page - # 目标为空且当前槽位也为空时,不需要打开选船页面。 if name is None and not slot_occupied: return None - # 点击目标槽位并等待选船页面加载完成。 - self.click_ship_slot(slot) - wait_for_page( - self._ctrl, - ChooseShipPage.is_current_page, - timeout=_CHOOSE_PAGE_TIMEOUT, - source='编队', - target='编队选船', - ) # FleetChange 决定候选顺序,页面每次只执行一条明确规则。 - choose_page = ChooseShipPage(self._ctx) + choose_page = self._open_choose_page(slot) if name is None: return choose_page.change_single_ship(None, use_search=self._use_search) @@ -621,5 +1104,6 @@ def _change_single_ship( return selected candidates = [option.name for option in options] + self._cancel_choose_page() _log.error('[准备页] 未在选船列表中找到满足规则的候选: {}', candidates) raise RuntimeError(f'未找到满足条件的目标舰船: {candidates}') diff --git a/autowsgr/ui/battle/fleet_change/_detect.py b/autowsgr/ui/battle/fleet_change/_detect.py index 4063fc14..88d445bb 100644 --- a/autowsgr/ui/battle/fleet_change/_detect.py +++ b/autowsgr/ui/battle/fleet_change/_detect.py @@ -15,16 +15,18 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING -from autowsgr.constants import SHIPNAMES +from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger +from autowsgr.types import ShipDamageState from autowsgr.ui.battle.base import BaseBattlePreparation +from autowsgr.ui.battle.detection import DetectionMixin from autowsgr.vision.ocr import ( _fuzzy_match, apply_ship_patches, ) -from autowsgr.vision.ocr_rules import normalize_ship_name_suffix # 仅在类型检查时导入运行逻辑不需要的类型。 @@ -51,6 +53,23 @@ _SHIP_FUZZY_THRESHOLD: int = 2 +@dataclass(slots=True) +class FleetSnapshot: + """同一准备页截图中的舰名和槽位占用状态。""" + + names: list[str | None] + occupied: list[bool] + + @property + def unknown_slots(self) -> list[int]: + """返回有舰船但舰名 OCR 未识别的槽位。""" + return [ + slot + for slot, (name, occupied) in enumerate(zip(self.names, self.occupied, strict=True)) + if name is None and occupied + ] + + # 负责识别准备页当前六个舰队槽位。 class FleetDetectMixin(BaseBattlePreparation): """提供准备页舰队 OCR 检测能力。""" @@ -82,6 +101,7 @@ def detect_fleet( screen: np.ndarray | None = None, *, expected_names: Sequence[str | None] | None = None, + expected_pool: Sequence[str] | None = None, ) -> list[str | None]: """返回长度为六的舰名列表,未占用槽位返回 None。""" # 未传入截图时直接获取当前屏幕。 @@ -100,14 +120,16 @@ def detect_fleet( ), ) expected_slots = ( - [ - normalize_ship_name_suffix(name) if isinstance(name, str) and name.strip() else None - for name in list(expected_names)[:6] - ] + [normalize_ship_name(name) for name in list(expected_names)[:6]] if expected_names is not None else [] ) expected_slots += [None] * (6 - len(expected_slots)) + normalized_pool = [ + normalized + for name in dict.fromkeys(expected_pool or ()) + if (normalized := normalize_ship_name(name)) is not None + ] prepared_results = [] for result in results: raw_text = result.text.strip() @@ -141,6 +163,9 @@ def detect_fleet( context_match = self._match_context_ship_name(text, [expected_name]) if context_match is not None: matched = context_match + # 位置尚未对齐时,全局目标池只能补救完整船池未识别的文字。 + elif matched is None and normalized_pool: + matched = self._match_context_ship_name(text, normalized_pool) recognized_ocr.append( { 'slot': slot, @@ -163,6 +188,27 @@ def detect_fleet( _log.info('[准备页] 当前舰队: {}', ships) return ships + def detect_fleet_snapshot( + self, + *, + expected_names: Sequence[str | None] | None = None, + expected_pool: Sequence[str] | None = None, + ) -> FleetSnapshot: + """使用同一截图识别舰名和槽位占用状态。""" + screen = self._ctrl.screenshot() + names = self.detect_fleet( + screen, + expected_names=expected_names, + expected_pool=expected_pool, + ) + damage = DetectionMixin.detect_ship_damage(screen) + occupied = [ + names[slot] is not None + or damage.get(slot, ShipDamageState.NO_SHIP) != ShipDamageState.NO_SHIP + for slot in range(6) + ] + return FleetSnapshot(names=names, occupied=occupied) + @staticmethod def _validate_fleet( current: list[str | None], diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index 923718c6..7b607707 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -16,7 +16,7 @@ import time from typing import TYPE_CHECKING -from autowsgr.constants import SHIPNAMES +from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger from autowsgr.types import ShipType from autowsgr.vision import ( @@ -26,7 +26,6 @@ PixelSignature, ) from autowsgr.vision.ocr import _fuzzy_match -from autowsgr.vision.ocr_rules import normalize_ship_name_suffix from .utils import wait_for_page, wait_leave_page from .utils.ship_list import LevelOCRRetryNeededError, locate_ship_rows, read_ship_levels @@ -348,12 +347,16 @@ def _click_ship_in_list( # noqa: C901, PLR0912 level_map: dict[float, dict[str, list[int | None]]] = {} for entry in raw_levels: level_name, level, row_key = self._normalize_level_entry(entry) - normalized_level_name = self._normalize_ship_name(level_name) + normalized_level_name = normalize_ship_name(level_name) + if normalized_level_name is None: + continue row_levels = level_map.setdefault(row_key, {}) row_levels.setdefault(normalized_level_name, []).append(level) for matched, cx, cy, row_key in hits: - normalized_matched = self._normalize_ship_name(matched) + normalized_matched = normalize_ship_name(matched) + if normalized_matched is None: + continue if not self._matches_ship_name(name, matched): continue @@ -471,19 +474,13 @@ def _normalize_search_keyword(name: str) -> str: """保留用户在游戏内使用的自定义舰名作为搜索条件。""" return name.strip() - @staticmethod - def _normalize_ship_name(name: str) -> str: - return normalize_ship_name_suffix(name) - @classmethod def _matches_ship_name(cls, target: str, matched: str) -> bool: """比较目标名与 OCR 船池结果,不修改任一原始文本。""" - normalized_target = cls._normalize_ship_name(target) - normalized_matched = cls._normalize_ship_name(matched) + normalized_target = normalize_ship_name(target) + normalized_matched = normalize_ship_name(matched) if normalized_target == normalized_matched: return True pool_target = _fuzzy_match(target, SHIPNAMES, threshold=0) - return ( - pool_target is not None and cls._normalize_ship_name(pool_target) == normalized_matched - ) + return pool_target is not None and normalize_ship_name(pool_target) == normalized_matched diff --git a/autowsgr/ui/decisive/fleet_ocr.py b/autowsgr/ui/decisive/fleet_ocr.py index 60bc1c28..b3d97543 100644 --- a/autowsgr/ui/decisive/fleet_ocr.py +++ b/autowsgr/ui/decisive/fleet_ocr.py @@ -15,7 +15,6 @@ from typing import TYPE_CHECKING import cv2 -import numpy as np from autowsgr.infra.logger import get_logger from autowsgr.types import FleetSelection diff --git a/autowsgr/vision/ocr.py b/autowsgr/vision/ocr.py index 16d17ede..f2336ea3 100644 --- a/autowsgr/vision/ocr.py +++ b/autowsgr/vision/ocr.py @@ -19,12 +19,11 @@ import easyocr -from autowsgr.constants import SHIPNAMES +from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger from autowsgr.vision.ocr_rules import ( apply_ship_name_rules, expand_ship_name_candidates, - resolve_ship_name_alias, ) @@ -446,7 +445,7 @@ def _fuzzy_match(text: str, candidates: list[str], threshold: int = 3) -> str | _ship_name_match_confidence, ) if handled: - return resolve_ship_name_alias(pool_name) if pool_name is not None else None + return normalize_ship_name(pool_name) # 单字只接受精确匹配,二至三字最多允许一个字符识别错误。 effective_threshold = ( @@ -456,7 +455,7 @@ def _fuzzy_match(text: str, candidates: list[str], threshold: int = 3) -> str | best_dist = min(distance for _, distance in distances) nearest = list( dict.fromkeys( - resolve_ship_name_alias(name) for name, distance in distances if distance == best_dist + normalize_ship_name(name) for name, distance in distances if distance == best_dist ), ) best_name = nearest[0] if len(nearest) == 1 and best_dist <= effective_threshold else None @@ -492,7 +491,7 @@ def _fuzzy_match_pool_aware( # noqa: PLR0911 """处理精确名称、明确自定义后缀和唯一长舰名片段。""" exact = [name for name in candidates if name == text] if exact: - name = resolve_ship_name_alias(exact[0]) + name = normalize_ship_name(exact[0]) _log.debug("[OCR] pool_match: '{}' -> '{}' (exact)", text, name) return name, True @@ -521,7 +520,7 @@ def _fuzzy_match_pool_aware( # noqa: PLR0911 ) if relation_count > 1: related_names = { - resolve_ship_name_alias(name) + normalize_ship_name(name) for matches in (custom_suffix_matches, truncated_matches, fragment_matches) for name in matches } @@ -548,7 +547,7 @@ def _fuzzy_match_pool_aware( # noqa: PLR0911 else: return None, False - standard_names = list(dict.fromkeys(resolve_ship_name_alias(name) for name in matches)) + standard_names = list(dict.fromkeys(normalize_ship_name(name) for name in matches)) if len(standard_names) != 1: if standard_names: _log.warning("[OCR] pool_match: '{}' 前缀候选不唯一: {}", text, standard_names) diff --git a/autowsgr/vision/ocr_rules.py b/autowsgr/vision/ocr_rules.py index b87832e0..39956689 100644 --- a/autowsgr/vision/ocr_rules.py +++ b/autowsgr/vision/ocr_rules.py @@ -15,8 +15,8 @@ ``No.xxx`` 同船名称列表。 4. 特殊分隔符:只在有实机日志证明某个符号被稳定误读时, 增加一个范围明确的正则;不要统一删除 ``/``、``-``、``·``。 -5. 舰名后缀:在 ``SHIP_NAME_SUFFIXES`` 中增加完整后缀, - 或增加只匹配末尾的正则,同时补充真实舰名不受影响的测试。 +5. 舰名后缀:统一由 ``autowsgr.constants.normalize_ship_name`` 处理。 + 修改规则时必须补充真实舰名不受影响的测试。 6. 等级字符:在 ``LEVEL_DIGIT_TRANSLATION`` 中增加 ``'OCR 字符': '数字'``;右侧必须是单个十进制数字。 7. 舰船等级范围固定为 1-110,不通过新增规则放宽上限。 @@ -27,14 +27,10 @@ import re from typing import TYPE_CHECKING -from autowsgr.constants import ( - canonical_ship_name, - get_ship_name_group_id, - set_ship_name_aliases, -) from autowsgr.constants import ( expand_ship_name_candidates as expand_group_candidates, ) +from autowsgr.constants import get_ship_name_group_id, set_ship_name_aliases from autowsgr.infra.logger import get_logger @@ -57,12 +53,6 @@ _USER_SHIP_NAME_CORRECTIONS: dict[str, str] = {} _USER_SHIP_NAME_ALIASES: dict[str, str] = {} -# 只处理已确认的完整后缀,不删除舰名中间的间隔号。 -SHIP_NAME_SUFFIXES: tuple[str, ...] = ('·改',) - -# 处理舰名末尾由括号包围的别名,例如“岛风(苍青幻影)”。 -SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') - # EasyOCR 会把中文舰名中的间隔号识别成冒号,只修正两个汉字之间的冒号。 _CJK_COLON_SEPARATOR_RE = re.compile(r'(?<=[\u3400-\u9fff]):(?=[\u3400-\u9fff])') @@ -134,11 +124,6 @@ def set_user_ship_name_aliases(aliases: Mapping[str, str]) -> int: return len(loaded) -def resolve_ship_name_alias(text: str) -> str: - """将用户补充的显示名转换为 SHIPNAMES 标准舰名。""" - return canonical_ship_name(text.strip()) - - def expand_ship_name_candidates(candidates: list[str]) -> list[str]: """将当前舰名候选扩展为同组全部名称。""" return expand_group_candidates(candidates) @@ -159,14 +144,6 @@ def apply_ship_name_rules(text: str) -> str: return text -def normalize_ship_name_suffix(text: str) -> str: - """去掉明确登记的舰名尾部标记,保留舰名内部特殊字符。""" - normalized = resolve_ship_name_alias(text) - for suffix in SHIP_NAME_SUFFIXES: - normalized = normalized.removesuffix(suffix) - return SHIP_ALIAS_SUFFIX_RE.sub('', normalized).strip() - - def normalize_level_digits(raw_digits: str) -> str | None: """将等级易混淆字符转成数字;包含其他字符时拒绝解析。""" normalized = raw_digits.translate(LEVEL_DIGIT_TRANSLATION) diff --git a/docs/features/ocr-ship-name-reliability.md b/docs/features/ocr-ship-name-reliability.md index e5822719..69a116f6 100644 --- a/docs/features/ocr-ship-name-reliability.md +++ b/docs/features/ocr-ship-name-reliability.md @@ -23,8 +23,9 @@ OCR 结果必须结合 YAML 槽位目标,保证最终进入战斗的舰队逐 ## 特殊规则 -系统规则集中在 `autowsgr/vision/ocr_rules.py`。每条规则必须有实机证据, -并补充对应测试。 +OCR 误识别修正规则集中在 `autowsgr/vision/ocr_rules.py`。舰名文本归一化和 +同舰身份判断唯一由 `autowsgr/constants/shipnames.py` 负责。每条规则必须有 +实机证据,并补充对应测试。 用户可在 YAML 中维护自己的舰名修正规则: diff --git a/docs/usage/usage_combat.md b/docs/usage/usage_combat.md index 04b682da..de29b9e6 100644 --- a/docs/usage/usage_combat.md +++ b/docs/usage/usage_combat.md @@ -330,11 +330,17 @@ enemy_rules: | 代号 | 舰种 | 代号 | 舰种 | |------|------|------|------| -| CV | 航母 | BB | 战列 | -| CA | 重巡 | CL | 轻巡 | -| DD | 驱逐 | SS | 潜艇 | -| SAP | 轻母 | BC | 战巡 | -| NAP | 重母 | BM | 浅水重炮 | +| CV | 航空母舰(航母) | CVL | 轻型航母(轻母) | +| AV | 装甲航母(装母) | BB | 战列舰(战列) | +| BBV | 航空战列舰(航战) | BC | 战列巡洋舰(战巡) | +| CA | 重巡洋舰(重巡) | CAV | 航空巡洋舰(航巡) | +| CLT | 重雷装巡洋舰(雷巡) | CL | 轻巡洋舰(轻巡) | +| BM | 浅水重炮舰(重炮) | DD | 驱逐舰(驱逐) | +| SSG | 导弹潜艇(导潜) | SS | 潜水艇(潜艇) | +| SC | 重炮潜艇(炮潜) | AP | 补给舰(补给) | +| ASDG | 反舰导弹驱逐舰(导驱) | AADG | 防空导弹驱逐舰(防驱) | +| KP | 反舰导弹巡洋舰(导巡) | CG | 防空导弹巡洋舰(防巡) | +| BG | 导弹大型巡洋舰(大巡) | BBG | 导弹战列舰(导战) | 支持的运算符: `==`, `!=`, `>`, `<`, `>=`, `<=` diff --git a/pyproject.toml b/pyproject.toml index 7890cd22..12916d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "keyboard", "easyocr>=1.7.1", "adbutils>=2.0,<3.0", - "autowsgr_native>=0.2.0", + "autowsgr_native>=0.3.0", "av>=12.0", "pydantic>=2.0,<3.0", # HTTP Server diff --git a/testing/ops/_framework.py b/testing/ops/_framework.py index ec4db9b6..71196079 100644 --- a/testing/ops/_framework.py +++ b/testing/ops/_framework.py @@ -70,7 +70,7 @@ def launch_for_test( if with_ocr: ctx = launcher.build_context() else: - ctx = GameContext(ctrl=launcher.ctrl, config=launcher.config) + ctx = GameContext(ctrl=launcher.ctrl, config=launcher.config, ocr=None) launcher.ensure_ready(ctx) return ctx diff --git a/testing/ops/normal_fight.py b/testing/ops/normal_fight.py index 7612dabd..298aa469 100644 --- a/testing/ops/normal_fight.py +++ b/testing/ops/normal_fight.py @@ -201,6 +201,7 @@ def main() -> None: # 导航到出征地图页 → 选择地图 → 进入准备页 result = runner.run() + results.append(result) logger.info( ' 战斗结果: {} 血量={}', diff --git a/testing/ops/test_normal_fight_unit.py b/testing/ops/test_normal_fight_unit.py index 1408255a..9afeacea 100644 --- a/testing/ops/test_normal_fight_unit.py +++ b/testing/ops/test_normal_fight_unit.py @@ -10,15 +10,18 @@ import pytest +import autowsgr.ops.normal_fight as normal_fight_module from autowsgr.combat import CombatMode, CombatPlan from autowsgr.combat.fleet import ( FleetSelectionSource, FleetSlotRule, ShipSelector, + exact_fleet_rules, resolve_fleet_selection, ) from autowsgr.infra import ActionFailedError from autowsgr.ops.normal_fight import NormalFightRunner, _require_fleet_change +from autowsgr.types import ShipDamageState, ShipType def _make_ctx() -> SimpleNamespace: @@ -149,6 +152,126 @@ def test_plan_preset_has_priority_over_plain_plan_fleet(self): assert selection.source is FleetSelectionSource.PLAN_PRESET +class _FleetInfo: + def __init__(self) -> None: + self.ship_damage: dict[int, ShipDamageState] = {} + + @staticmethod + def to_ships(_names: list[str | None] | None) -> list[object]: + return [] + + +class _BattlePreparationPage: + def __init__(self) -> None: + self.changed_fleet_id: int | None = None + self.changed_rules: tuple[FleetSlotRule, ...] | None = None + self.last_changed_fleet: list[str | None] | None = ['导巡测试舰'] + + @staticmethod + def select_fleet(_fleet_id: int) -> None: + return None + + def change_fleet( + self, + fleet_id: int, + rules: tuple[FleetSlotRule, ...], + ) -> bool: + self.changed_fleet_id = fleet_id + self.changed_rules = rules + return True + + @staticmethod + def detect_fleet() -> list[str]: + raise AssertionError('runner 不应在换船成功后重复识别舰队') + + @staticmethod + def apply_supply() -> None: + return None + + @staticmethod + def apply_repair(_strategy: object) -> None: + return None + + @staticmethod + def detect_fleet_info() -> _FleetInfo: + return _FleetInfo() + + @staticmethod + def start_battle() -> None: + return None + + +class TestFleetSelectionCallChain: + def _prepare( + self, + monkeypatch: pytest.MonkeyPatch, + plan: CombatPlan, + ) -> tuple[object, _BattlePreparationPage]: + selection = resolve_fleet_selection(plan) + page = _BattlePreparationPage() + monkeypatch.setattr( + normal_fight_module, + 'BattlePreparationPage', + lambda _ctx: page, + ) + monkeypatch.setattr(normal_fight_module.time, 'sleep', lambda _seconds: None) + + runner = NormalFightRunner(_make_ctx(), plan, selection) + runner._prepare_for_battle() + return selection, page + + def test_yaml_preset_rules_reach_battle_preparation_unchanged( + self, + monkeypatch: pytest.MonkeyPatch, + ): + plan = CombatPlan.from_dict( + { + 'fleet_id': 4, + 'fleet': ['被预设覆盖的舰船'], + 'fleet_presets': [ + { + 'ships': [ + { + 'name': '导巡测试舰', + 'ship_type': ['kp'], + 'min_level': 90, + }, + ], + }, + ], + }, + ) + + selection, page = self._prepare(monkeypatch, plan) + + assert selection.source is FleetSelectionSource.PLAN_PRESET + assert selection.slot_rules is not None + assert page.changed_fleet_id == 4 + assert page.changed_rules is selection.slot_rules + assert page.changed_rules[0].primary == ShipSelector( + name='导巡测试舰', + ship_types=(ShipType.KP,), + min_level=90, + ) + + def test_plain_plan_fleet_is_converted_once_at_battle_preparation( + self, + monkeypatch: pytest.MonkeyPatch, + ): + plan = CombatPlan.from_dict( + { + 'fleet_id': 2, + 'fleet': ['岛风', '雪风'], + }, + ) + + selection, page = self._prepare(monkeypatch, plan) + + assert selection.source is FleetSelectionSource.PLAN_FLEET + assert page.changed_fleet_id == 2 + assert page.changed_rules == exact_fleet_rules(['岛风', '雪风']) + + class TestEventNormalMerge: """chapter (E/H vs 数字) 决定导航分支与 plan.mode。""" diff --git a/testing/server/test_task_routes.py b/testing/server/test_task_routes.py index 3265d86a..dfb14c0d 100644 --- a/testing/server/test_task_routes.py +++ b/testing/server/test_task_routes.py @@ -9,19 +9,31 @@ import pytest from fastapi import HTTPException +from autowsgr import ops +from autowsgr.combat import CombatResult +from autowsgr.combat.fleet import FleetSelectionSource, ResolvedFleetSelection from autowsgr.server import main as server_main from autowsgr.server.device_lease import DeviceOperationBusyError from autowsgr.server.routes import task from autowsgr.server.schemas import ( ApiResponse, CampaignRequest, + CombatPlanRequest, DecisiveRequest, EventFightRequest, ExerciseRequest, + FleetRuleRequest, NormalFightRequest, RoundResult, TaskStatusResponse, ) +from autowsgr.types import ConditionFlag, ShipType + + +if TYPE_CHECKING: + from pathlib import Path + + from autowsgr.server.task_manager import TaskOutcome if TYPE_CHECKING: @@ -38,26 +50,35 @@ class _TaskManager: @dataclass class _ExecutingTaskManager: + """同步执行 route 创建的 executor,避免测试启动后台线程。""" + is_running: bool = False + stop_event: object = field(default_factory=object) outcome: TaskOutcome | None = None + results: list[dict[str, Any]] = field(default_factory=list) - def should_stop(self) -> bool: + @staticmethod + def should_stop() -> bool: return False - def update_progress(self, **_progress: object) -> None: + @staticmethod + def update_progress(**_progress: object) -> None: return None - def add_result(self, _result: dict[str, Any]) -> None: - return None + def add_result(self, result: dict[str, Any]) -> None: + self.results.append(result) def start_task( self, - task_type: str, - total_rounds: int, - executor: Callable[[object], TaskOutcome], + *args: object, + task_type: str | None = None, + total_rounds: int | None = None, + executor: Callable[[object], TaskOutcome] | None = None, ) -> str: - del task_type, total_rounds + if args: + task_type, total_rounds, executor = args # type: ignore[misc] + assert executor is not None self.outcome = executor(object()) - return 'task_decisive' + return 'task_test' def test_task_start_rejects_concurrent_task(monkeypatch: pytest.MonkeyPatch) -> None: @@ -274,3 +295,134 @@ def test_task_status_returns_typed_envelope(monkeypatch: pytest.MonkeyPatch) -> 'success': True, 'data': status, } +@pytest.mark.parametrize( + 'route_case', + [ + ('_start_normal_fight', NormalFightRequest, 'run_normal_fight'), + ('_start_event_fight', EventFightRequest, 'run_event_fight'), + ], +) +@pytest.mark.parametrize( + ('fleet_source', 'expected_source', 'expected_name'), + [ + ('api_rules', FleetSelectionSource.OVERRIDE_RULES, 'API规则舰'), + ('api_fleet', FleetSelectionSource.OVERRIDE_FLEET, 'API普通舰'), + ('yaml_preset', FleetSelectionSource.PLAN_PRESET, 'YAML预设舰'), + ('yaml_fleet', FleetSelectionSource.PLAN_FLEET, 'YAML普通舰'), + ], +) +def test_fight_routes_resolve_all_fleet_sources_before_runner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + route_case: tuple[ + str, + type[NormalFightRequest | EventFightRequest], + str, + ], + fleet_source: str, + expected_source: FleetSelectionSource, + expected_name: str, +) -> None: + """normal/event route 都只向 runner 传递解析完成的 canonical selection。""" + helper_name, request_type, run_name = route_case + manager = _ExecutingTaskManager() + captured: list[ResolvedFleetSelection] = [] + + def run_fight( + _ctx: object, + _plan: object, + *, + times: int, + fleet_selection: ResolvedFleetSelection, + ) -> list[CombatResult]: + assert times == 1 + captured.append(fleet_selection) + return [CombatResult(flag=ConditionFlag.OPERATION_SUCCESS)] + + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, run_name, run_fight) + + if fleet_source == 'api_rules': + plan_request = CombatPlanRequest( + fleet_id=3, + fleet=['被规则覆盖的API舰'], + fleet_rules=[ + FleetRuleRequest( + name=expected_name, + ship_type=['kp'], + min_level=90, + ), + ], + ) + request = request_type(plan=plan_request) + elif fleet_source == 'api_fleet': + request = request_type( + plan=CombatPlanRequest( + fleet_id=3, + fleet=[expected_name], + ), + ) + else: + yaml_path = tmp_path / f'{fleet_source}.yaml' + preset = ( + '\nfleet_presets:\n' + ' - name: route测试\n' + ' ships:\n' + f' - name: {expected_name}\n' + ' ship_type: [cg]\n' + if fleet_source == 'yaml_preset' + else '' + ) + yaml_path.write_text( + f'chapter: 1\nmap: 1\nfleet_id: 2\nfleet:\n - {expected_name}\n{preset}', + encoding='utf-8', + ) + request = request_type(plan_id=str(yaml_path)) + + response = asyncio.run(getattr(task, helper_name)(object(), request)) + + assert response.success is True + assert manager.outcome is not None + assert manager.outcome.success is True + assert len(captured) == 1 + selection = captured[0] + assert selection.source is expected_source + assert selection.fleet_id == (3 if fleet_source.startswith('api_') else 2) + assert selection.primary_names == [expected_name] + if fleet_source == 'api_rules': + assert selection.slot_rules is not None + assert selection.slot_rules[0].primary is not None + assert selection.slot_rules[0].primary.ship_types == (ShipType.KP,) + elif fleet_source == 'yaml_preset': + assert selection.slot_rules is not None + assert selection.slot_rules[0].primary is not None + assert selection.slot_rules[0].primary.ship_types == (ShipType.CG,) + + +def test_event_route_top_level_fleet_id_overrides_api_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _ExecutingTaskManager() + captured: list[ResolvedFleetSelection] = [] + + def run_event_fight( + _ctx: object, + _plan: object, + *, + times: int, + fleet_selection: ResolvedFleetSelection, + ) -> list[CombatResult]: + assert times == 1 + captured.append(fleet_selection) + return [CombatResult(flag=ConditionFlag.OPERATION_SUCCESS)] + + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(ops, 'run_event_fight', run_event_fight) + request = EventFightRequest( + plan=CombatPlanRequest(fleet_id=3, fleet=['岛风']), + fleet_id=5, + ) + + asyncio.run(task._start_event_fight(object(), request)) + + assert captured[0].fleet_id == 5 diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index 6d0b3d31..93a4fd5f 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -1,12 +1,15 @@ """后端编队请求契约的定向测试。""" import pytest +from autowsgr_native.vessel_type import VesselType from pydantic import ValidationError from autowsgr.combat import CombatPlan from autowsgr.combat.fleet import ( + NATIVE_VESSEL_TYPE_BY_CODE, FleetSelectionSource, fleet_slot_from_api, + ship_type_from_native, ) from autowsgr.server.schemas import ( CombatPlanRequest, @@ -135,13 +138,29 @@ def test_invalid_candidate_ship_type_is_rejected(): @pytest.mark.parametrize( ('code', 'expected'), [ - ('ap', (ShipType.NAP,)), - ('bbg', (ShipType.BG,)), + ('aadg', (ShipType.AADG,)), + ('ap', (ShipType.AP,)), + ('asdg', (ShipType.ASDG,)), + ('av', (ShipType.AV,)), + ('bb', (ShipType.BB,)), + ('bbg', (ShipType.BBG,)), + ('bbv', (ShipType.BBV,)), + ('bc', (ShipType.BC,)), + ('bg', (ShipType.BG,)), + ('bm', (ShipType.BM,)), + ('ca', (ShipType.CA,)), + ('cav', (ShipType.CAV,)), + ('cg', (ShipType.CG,)), + ('cl', (ShipType.CL,)), + ('clt', (ShipType.CLT,)), + ('cv', (ShipType.CV,)), + ('cvl', (ShipType.CVL,)), + ('dd', (ShipType.DD,)), + ('kp', (ShipType.KP,)), ('sc', (ShipType.SC,)), - ('ddg', (ShipType.ASDG,)), - ('ddgaa', (ShipType.AADG,)), - ('cg', (ShipType.KP,)), - ('cgaa', (ShipType.CG,)), + ('ss', (ShipType.SS,)), + ('ssg', (ShipType.SSG,)), + ('ss_or_ssg', (ShipType.SS, ShipType.SSG)), ], ) def test_api_ship_type_code_maps_to_domain_enum( @@ -155,9 +174,78 @@ def test_api_ship_type_code_maps_to_domain_enum( assert slot.primary.ship_types == expected -def test_removed_cf_ship_type_is_rejected(): +@pytest.mark.parametrize( + ('native_type', 'expected'), + [ + (VesselType.AADG, ShipType.AADG), + (VesselType.AP, ShipType.AP), + (VesselType.ASDG, ShipType.ASDG), + (VesselType.AV, ShipType.AV), + (VesselType.BB, ShipType.BB), + (VesselType.BBG, ShipType.BBG), + (VesselType.BBV, ShipType.BBV), + (VesselType.BC, ShipType.BC), + (VesselType.BG, ShipType.BG), + (VesselType.BM, ShipType.BM), + (VesselType.CA, ShipType.CA), + (VesselType.CAV, ShipType.CAV), + (VesselType.CG, ShipType.CG), + (VesselType.CL, ShipType.CL), + (VesselType.CLT, ShipType.CLT), + (VesselType.CV, ShipType.CV), + (VesselType.CVL, ShipType.CVL), + (VesselType.DD, ShipType.DD), + (VesselType.KP, ShipType.KP), + (VesselType.SC, ShipType.SC), + (VesselType.SS, ShipType.SS), + (VesselType.SSG, ShipType.SSG), + ], +) +def test_native_vessel_type_maps_to_domain_enum( + native_type: VesselType, + expected: ShipType, +): + assert ship_type_from_native(native_type) is expected + assert native_type.as_english() == expected.name + assert native_type.as_chinese() == expected.value + + +def test_native_fleet_codes_are_complete(): + assert set(NATIVE_VESSEL_TYPE_BY_CODE) == { + 'cv', + 'cvl', + 'av', + 'bb', + 'bbv', + 'bc', + 'ca', + 'cav', + 'clt', + 'cl', + 'bm', + 'dd', + 'ssg', + 'ss', + 'sc', + 'ap', + 'asdg', + 'aadg', + 'kp', + 'cg', + 'bg', + 'bbg', + } + + +def test_non_fleet_native_type_is_rejected(): + with pytest.raises(ValueError, match='不支持的 native 舰种'): + ship_type_from_native(VesselType.Airfield) + + +@pytest.mark.parametrize('code', ['cf', 'cgaa', 'cbg', 'ddg', 'ddgaa']) +def test_noncanonical_ship_type_is_rejected(code: str): with pytest.raises(ValidationError, match='ship_type 不合法'): - FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': ['cf']}) + FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': [code]}) def test_yaml_and_api_candidate_only_rules_share_canonical_model(): @@ -203,11 +291,7 @@ def test_event_fleet_id_priority_is_resolved_at_server_boundary( ): """活动顶层覆盖、API plan 和 YAML plan 使用统一优先级。""" plan = CombatPlan(fleet_id=plan_id) - request = ( - CombatPlanRequest(fleet_id=request_id) - if request_id is not None - else None - ) + request = CombatPlanRequest(fleet_id=request_id) if request_id is not None else None selection = build_fleet_selection( plan, @@ -262,4 +346,29 @@ def test_api_combat_plan_parses_event_entrance_and_node_fields(): assert plan.default_node.SL_when_spot_enemy_fails is True assert plan.default_node.formation_when_spot_enemy_fails.value == 3 assert plan.nodes['A'].formation_rules is not None + assert plan.nodes['A'].enemy_rules is not None + assert plan.nodes['A'].SL_when_spot_enemy_fails is True + assert plan.nodes['A'].formation_when_spot_enemy_fails.value == 3 assert plan.nodes['A'].SL_when_enter_fight is True + + +def test_api_node_args_inherit_defaults_and_keep_explicit_overrides(): + request = CombatPlanRequest( + node_defaults=NodeDecisionRequest( + formation=4, + night=True, + detour=True, + ), + node_args={ + 'A': NodeDecisionRequest( + formation=3, + detour=False, + ), + }, + ) + + decision = build_combat_plan(request).nodes['A'] + + assert decision.formation.value == 3 + assert decision.night is True + assert decision.detour is False diff --git a/testing/test_vessel_type_contract.py b/testing/test_vessel_type_contract.py new file mode 100644 index 00000000..b76b6ca9 --- /dev/null +++ b/testing/test_vessel_type_contract.py @@ -0,0 +1,28 @@ +"""native 舰种公共契约测试。""" + +from autowsgr.contracts.vessel_types import ( + FLEET_VESSEL_TYPE_BY_CODE, + FLEET_VESSEL_TYPES, + fleet_vessel_type_contract, + fleet_vessel_type_from_code, +) + + +def test_fleet_vessel_type_contract_is_derived_from_native(): + payload = fleet_vessel_type_contract() + + assert payload['schema_version'] == 1 + assert payload['source'] == 'autowsgr_native.vessel_type.VesselType' + assert payload['ship_types'] == [ + {'code': vessel_type.code, 'label': vessel_type.native.as_chinese()} + for vessel_type in FLEET_VESSEL_TYPES + ] + assert set(FLEET_VESSEL_TYPE_BY_CODE) == { + vessel_type.native.as_english().lower() for vessel_type in FLEET_VESSEL_TYPES + } + assert 'no' not in FLEET_VESSEL_TYPE_BY_CODE + + +def test_guided_missile_cruiser_codes_follow_native_semantics(): + assert fleet_vessel_type_from_code('KP').label == '导巡' + assert fleet_vessel_type_from_code('cg').label == '防巡' diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index 3c9aa372..1f604cfb 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -14,11 +14,12 @@ exact_fleet_rules, fleet_slot_from_api, ) +from autowsgr.constants import normalize_ship_name from autowsgr.context import GameContext from autowsgr.emulator import AndroidController from autowsgr.infra import DecisiveConfig from autowsgr.server.schemas import FleetRuleRequest -from autowsgr.types import ShipType +from autowsgr.types import ShipDamageState, ShipType from autowsgr.ui.battle.base import PAGE_SIGNATURE from autowsgr.ui.battle.constants import ( AUTO_SUPPLY_PROBE, @@ -29,6 +30,8 @@ CLICK_SUPPORT, FLEET_PROBE, ) +from autowsgr.ui.battle.fleet_change._change import _ShipSelection +from autowsgr.ui.battle.fleet_change._detect import FleetSnapshot from autowsgr.ui.battle.preparation import ( CLICK_PANEL, PANEL_PROBE, @@ -96,6 +99,17 @@ def _candidate_rule( ) +def _snapshot( + names: list[str | None], + occupied: list[bool] | None = None, +) -> FleetSnapshot: + """构造不会与测试输入共享列表的舰队快照。""" + return FleetSnapshot( + names=list(names), + occupied=list(occupied) if occupied is not None else [name is not None for name in names], + ) + + def _set_pixel(screen: np.ndarray, rx: float, ry: float, rgb: tuple[int, int, int]) -> None: """在相对坐标处设置像素颜色(与 PixelChecker.get_pixel 使用相同算法)。""" h, w = screen.shape[:2] @@ -408,6 +422,112 @@ def test_unrelated_ocr_does_not_force_slot_target(self): assert detected == [None] * 6 + def test_global_target_pool_only_rescues_unmatched_text(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + ocr.recognize.return_value = [ + OCRResult(text='雪凤', confidence=0.8, bbox=(127, 3, 167, 23)), + ] + page = BattlePreparationPage(_make_ctx(ctrl, ocr)) + + with patch( + 'autowsgr.ui.battle.fleet_change._detect._fuzzy_match', + side_effect=[None, '雪风'], + ): + detected = page.detect_fleet( + np.zeros((720, 1280, 3), dtype=np.uint8), + expected_pool=['雪风'], + ) + + assert detected == ['雪风', None, None, None, None, None] + + def test_global_target_pool_does_not_override_pool_match(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + ocr.recognize.return_value = [ + OCRResult(text='岛风', confidence=0.8, bbox=(127, 3, 167, 23)), + ] + page = BattlePreparationPage(_make_ctx(ctrl, ocr)) + + with patch( + 'autowsgr.ui.battle.fleet_change._detect._fuzzy_match', + return_value='岛风', + ): + detected = page.detect_fleet( + np.zeros((720, 1280, 3), dtype=np.uint8), + expected_pool=['雪风'], + ) + + assert detected == ['岛风', None, None, None, None, None] + + def test_snapshot_uses_same_screen_for_names_and_occupancy(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + ctrl.screenshot.return_value = screen + page = BattlePreparationPage(_make_ctx(ctrl, ocr)) + names = ['岛风', None, None, None, None, None] + damage = { + 0: ShipDamageState.NORMAL, + **dict.fromkeys(range(1, 6), ShipDamageState.NO_SHIP), + } + + with ( + patch.object(page, 'detect_fleet', return_value=names) as detect, + patch( + 'autowsgr.ui.battle.fleet_change._detect.DetectionMixin.detect_ship_damage', + return_value=damage, + ) as detect_damage, + ): + snapshot = page.detect_fleet_snapshot(expected_pool=['岛风']) + + assert snapshot == _snapshot(names) + assert detect.call_args.args[0] is screen + assert detect.call_args.kwargs == { + 'expected_names': None, + 'expected_pool': ['岛风'], + } + assert detect_damage.call_args.args[0] is screen + + def test_recognized_name_keeps_slot_occupied_when_probe_misses(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + ctrl.screenshot.return_value = np.zeros((720, 1280, 3), dtype=np.uint8) + page = BattlePreparationPage(_make_ctx(ctrl, ocr)) + + with ( + patch.object( + page, + 'detect_fleet', + return_value=['岛风', None, None, None, None, None], + ), + patch( + 'autowsgr.ui.battle.fleet_change._detect.DetectionMixin.detect_ship_damage', + return_value=dict.fromkeys(range(6), ShipDamageState.NO_SHIP), + ), + ): + snapshot = page.detect_fleet_snapshot() + + assert snapshot.occupied == [True, False, False, False, False, False] + + def test_initial_snapshot_retries_unknown_occupied_slot(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + first = _snapshot([None] * 6, [True, False, False, False, False, False]) + second = _snapshot(['岛风', None, None, None, None, None]) + + with patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[first, second], + ) as detect: + snapshot = page._detect_initial_snapshot(['岛风']) + + assert snapshot == second + assert detect.call_args_list == [ + call(expected_pool=['岛风']), + call(expected_pool=['岛风']), + ] + def test_user_ship_name_alias_is_used_for_final_fleet_detection(self): ctrl = MagicMock(spec=AndroidController) ocr = MagicMock() @@ -552,15 +672,26 @@ def test_custom_name_search_accepts_standard_name_result(self): set_user_ship_name_aliases({'契卡洛夫': '85工程'}) old_fleet = ['岛风', None, None, None, None, None] target_fleet = ['85工程', None, None, None, None, None] + snapshots = [ + _snapshot(old_fleet), + _snapshot(target_fleet), + _snapshot(target_fleet), + _snapshot(target_fleet), + ] with ( patch.object(page, 'get_selected_fleet', return_value=1), patch.object( page, - 'detect_fleet', - side_effect=[old_fleet, target_fleet, target_fleet, target_fleet], + 'detect_fleet_snapshot', + side_effect=snapshots, ), - patch.object(page, '_change_single_ship', return_value='85工程') as change_ship, + patch.object( + page, + '_try_select_option', + side_effect=lambda _slot, option: _ShipSelection('85工程', option), + ) as select_option, + patch.object(page, '_change_single_ship', return_value=None) as change_ship, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): assert page.change_fleet( @@ -568,10 +699,11 @@ def test_custom_name_search_accepts_standard_name_result(self): [_rule({'candidates': [{'name': '契卡洛夫'}]})], ) - assert change_ship.call_args.args[:2] == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector'] == ( + assert select_option.call_args == call( + 1, ShipSelector(name='契卡洛夫', relaxed_constraints=True), ) + change_ship.assert_called_once_with(0, None, slot_occupied=True) def test_existing_group_variant_is_reordered_without_reselection(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) @@ -586,9 +718,15 @@ def move_ship(src: int, dst: int, current: list[str | None]) -> None: patch.object(page, 'get_selected_fleet', return_value=1), patch.object( page, - 'detect_fleet', - side_effect=[old_fleet, old_fleet, old_fleet, target_fleet], + 'detect_fleet_snapshot', + side_effect=[ + _snapshot(old_fleet), + _snapshot(old_fleet), + _snapshot(old_fleet), + _snapshot(target_fleet), + ], ), + patch.object(page, '_try_select_option') as select_option, patch.object(page, '_change_single_ship') as change_ship, patch.object(page, '_circular_move', side_effect=move_ship) as circular_move, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), @@ -601,41 +739,89 @@ def move_ship(src: int, dst: int, current: list[str | None]) -> None: ], ) + select_option.assert_not_called() change_ship.assert_not_called() assert circular_move.call_args.args[:2] == (1, 0) + def test_candidate_only_reuses_existing_nonpreferred_candidate(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + old_fleet = ['岛风', '扶桑', None, None, None, None] + target_fleet = ['扶桑', '岛风', None, None, None, None] + + def move_ship(src: int, dst: int, current: list[str | None]) -> None: + current.insert(dst, current.pop(src)) + + with ( + patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[ + _snapshot(old_fleet), + _snapshot(old_fleet), + _snapshot(old_fleet), + _snapshot(target_fleet), + ], + ), + patch.object(page, '_try_select_option') as select_option, + patch.object(page, '_change_single_ship') as change_ship, + patch.object(page, '_circular_move', side_effect=move_ship) as circular_move, + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), + ): + assert page.change_fleet( + None, + [ + _candidate_rule('胡德', '扶桑'), + *exact_fleet_rules(['岛风']), + ], + ) + + select_option.assert_not_called() + change_ship.assert_not_called() + assert circular_move.call_args.args[:2] == (1, 0) + assert page.last_changed_fleet == target_fleet + def test_first_fleet_replaces_before_removing_extra_ship(self): - """1 队从 AB 改为 C 时,先替换槽位 0,再移除 B。""" + """1 队从 AB 改为 C 时,先补入 C,再删除 A/B。""" page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) fleet_a_b = ['A', 'B', None, None, None, None] fleet_c = ['C', None, None, None, None, None] + actions: list[tuple[str, int, str | None]] = [] + + def select_option(slot: int, option: ShipSelector) -> _ShipSelection: + actions.append(('select', slot, option.name)) + return _ShipSelection(option.name, option) + + def change_ship( + slot: int, + name: str | None, + *, + slot_occupied: bool, + ) -> None: + assert slot_occupied + actions.append(('remove', slot, name)) with ( patch.object(page, 'get_selected_fleet', return_value=1), patch.object( page, - 'detect_fleet', - side_effect=[fleet_a_b, fleet_c, fleet_c, fleet_c], - ) as detect, - patch.object( - page, - '_change_single_ship', - side_effect=['C', None], - ) as change_ship, + 'detect_fleet_snapshot', + side_effect=[ + _snapshot(fleet_a_b), + _snapshot(fleet_c), + _snapshot(fleet_c), + _snapshot(fleet_c), + ], + ), + patch.object(page, '_try_select_option', side_effect=select_option), + patch.object(page, '_change_single_ship', side_effect=change_ship), patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): assert page.change_fleet(1, exact_fleet_rules(['C'])) - actions = [ - (item.args[0], item.args[1], item.kwargs['slot_occupied']) - for item in change_ship.call_args_list - ] - assert actions == [(0, 'C', True), (1, None, True)] - assert detect.call_args_list == [ - call(), - call(expected_names=fleet_c), - call(expected_names=fleet_c), - call(expected_names=fleet_c), + assert actions == [ + ('select', 2, 'C'), + ('remove', 1, None), + ('remove', 0, None), ] def test_first_fleet_cannot_be_empty(self): @@ -643,7 +829,7 @@ def test_first_fleet_cannot_be_empty(self): with ( patch.object(page, 'get_selected_fleet', return_value=1), - patch.object(page, 'detect_fleet') as detect, + patch.object(page, 'detect_fleet_snapshot') as detect, pytest.raises(ValueError, match='1 队槽位 0 不能为空'), ): page.change_fleet(1, ()) @@ -654,15 +840,19 @@ def test_input_over_six_slots_is_truncated(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) target = ['A', 'B', 'C', 'D', 'E', 'F'] - with patch.object(page, 'detect_fleet', return_value=target) as detect: + with patch.object( + page, + 'detect_fleet_snapshot', + return_value=_snapshot(target), + ) as detect: assert page.change_fleet(None, exact_fleet_rules([*target, 'G'])) - detect.assert_called_once_with() + detect.assert_called_once_with(expected_pool=target) def test_duplicate_fixed_names_fail_before_ocr(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) - with patch.object(page, 'detect_fleet') as detect: + with patch.object(page, 'detect_fleet_snapshot') as detect: assert not page.change_fleet(None, exact_fleet_rules(['A', 'A'])) detect.assert_not_called() @@ -672,7 +862,11 @@ def test_failed_verification_uses_two_local_retries(self): wrong = ['X', None, None, None, None, None] with ( - patch.object(page, 'detect_fleet', return_value=wrong), + patch.object( + page, + 'detect_fleet_snapshot', + return_value=_snapshot(wrong), + ) as detect, patch.object(page, '_full_align') as full_align, patch.object(page, '_local_fix') as local_fix, patch.object(page, '_reorder'), @@ -682,6 +876,18 @@ def test_failed_verification_uses_two_local_retries(self): assert full_align.call_count == 1 assert local_fix.call_count == 2 + expected_names = ['A', None, None, None, None, None] + assert detect.call_args_list == [ + call(expected_pool=['A']), + call(expected_pool=['A']), + call(expected_names=expected_names), + call(expected_names=expected_names), + call(expected_pool=['A']), + call(expected_names=expected_names), + call(expected_names=expected_names), + call(expected_pool=['A']), + call(expected_names=expected_names), + ] class TestFleetSlotRules: @@ -696,7 +902,7 @@ class TestFleetSlotRules: ], ) def test_normalize_ship_name(self, raw: object, expected: str | None): - assert BattlePreparationPage._normalize_ship_name(raw) == expected + assert normalize_ship_name(raw) == expected def test_primary_and_candidates_keep_independent_rules(self): selector = _rule( @@ -779,7 +985,7 @@ def test_candidate_only_rules_keep_order_and_relax_constraints(self): ), ) - def test_existing_strict_primary_is_reused_without_reselection(self): + def test_existing_strict_primary_is_reselected_for_constraint_validation(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) rule = _rule( { @@ -790,13 +996,34 @@ def test_existing_strict_primary_is_reused_without_reselection(self): }, ) current = ['密苏里', None, None, None, None, None] + option = rule.primary + assert option is not None with ( - patch.object(page, 'detect_fleet', return_value=current), + patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[_snapshot(current) for _ in range(4)], + ), + patch.object( + page, + '_try_select_option', + return_value=_ShipSelection('密苏里', option), + ) as select_option, patch.object(page, '_change_single_ship') as change_ship, + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): assert page.change_fleet(None, [rule]) + select_option.assert_called_once_with( + 0, + ShipSelector( + name='密苏里', + ship_types=(ShipType.BB,), + min_level=100, + max_level=110, + ), + ) change_ship.assert_not_called() def test_existing_candidate_only_ship_keeps_relaxed_constraints(self): @@ -815,12 +1042,159 @@ def test_existing_candidate_only_ship_keeps_relaxed_constraints(self): current = ['胡德', None, None, None, None, None] with ( - patch.object(page, 'detect_fleet', return_value=current), + patch.object( + page, + 'detect_fleet_snapshot', + return_value=_snapshot(current), + ), + patch.object(page, '_try_select_option') as select_option, + patch.object(page, '_change_single_ship') as change_ship, + ): + assert page.change_fleet(None, [rule]) + + select_option.assert_not_called() + change_ship.assert_not_called() + + def test_existing_candidate_does_not_replace_available_strict_primary(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = _rule( + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 100, + 'candidates': [{'name': '衣阿华'}], + }, + ) + current = ['衣阿华', None, None, None, None, None] + target = ['密苏里', None, None, None, None, None] + primary = rule.primary + assert primary is not None + + with ( + patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[ + _snapshot(current), + _snapshot(target), + _snapshot(target), + _snapshot(target), + ], + ), + patch.object( + page, + '_try_select_option', + return_value=_ShipSelection('密苏里', primary), + ) as select_option, + patch.object(page, '_change_single_ship', return_value=None), + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), + ): + assert page.change_fleet(None, [rule]) + + select_option.assert_called_once_with(1, primary) + assert page.last_changed_fleet == target + + def test_strict_primary_failure_then_reuses_existing_candidate(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = _rule( + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 100, + 'candidates': [{'name': '衣阿华'}], + }, + ) + current = ['衣阿华', None, None, None, None, None] + primary = rule.primary + assert primary is not None + + with ( + patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[_snapshot(current) for _ in range(4)], + ), + patch.object( + page, + '_try_select_option', + return_value=_ShipSelection(None, primary), + ) as select_option, patch.object(page, '_change_single_ship') as change_ship, + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): assert page.change_fleet(None, [rule]) + select_option.assert_called_once_with(1, primary) change_ship.assert_not_called() + assert page.last_changed_fleet == current + + def test_primary_identity_is_reserved_from_candidate_only_slot(self): + primary_rule = FleetSlotRule(primary=ShipSelector(name='A')) + candidate_only = _candidate_rule('A', 'B') + assigned = BattlePreparationPage._plan_target_options( + [candidate_only, primary_rule, None, None, None, None], + ['A', 'B', None, None, None, None], + ) + + assert BattlePreparationPage._target_names(assigned or []) == [ + 'B', + 'A', + None, + None, + None, + None, + ] + + def test_fallback_replans_all_unlocked_candidate_slots(self): + selectors: list[FleetSlotRule | None] = [ + FleetSlotRule( + primary=ShipSelector(name='A'), + candidates=(ShipSelector(name='B', relaxed_constraints=True),), + ), + _candidate_rule('B', 'C'), + None, + None, + None, + None, + ] + primary = selectors[0].primary + assert primary is not None + assigned = BattlePreparationPage._plan_target_options( + selectors, + ['B', 'C', None, None, None, None], + {(0, primary)}, + ) + + assert BattlePreparationPage._target_names(assigned or []) == [ + 'B', + 'C', + None, + None, + None, + None, + ] + + def test_same_name_fallback_keeps_exact_relaxed_rule(self): + rule = _rule( + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 100, + 'candidates': [{'name': '密苏里'}], + }, + ) + primary = rule.primary + assert primary is not None + assigned = BattlePreparationPage._plan_target_options( + [rule, None, None, None, None, None], + unavailable={(0, primary)}, + ) + + assert assigned is not None + assert assigned[0] == ShipSelector( + name='密苏里', + relaxed_constraints=True, + ) def test_candidate_only_slots_use_backtracking(self): selectors = [ @@ -832,8 +1206,7 @@ def test_candidate_only_slots_use_backtracking(self): None, ] names = [ - selector.preferred_name if selector is not None else None - for selector in selectors + selector.preferred_name if selector is not None else None for selector in selectors ] assert BattlePreparationPage._assign_unique_targets( @@ -888,9 +1261,7 @@ def test_occupied_name_is_removed_from_slot_candidates(self): ) assert selected == '雪风' - assert selector == ( - ShipSelector(name='雪风', relaxed_constraints=True), - ) + assert selector == (ShipSelector(name='雪风', relaxed_constraints=True),) def test_replacing_same_slot_may_keep_current_name(self): selected, _selector = BattlePreparationPage._select_available_candidate( @@ -929,6 +1300,35 @@ def test_final_validation_rejects_duplicate_names(self): [None] * 6, ) + def test_strict_constraints_require_selection_verification(self): + current = ['密苏里', None, None, None, None, None] + selectors: list[FleetSlotRule | None] = [ + _rule( + { + 'name': '密苏里', + 'ship_type': ['BB'], + 'min_level': 100, + }, + ), + None, + None, + None, + None, + None, + ] + + assert not BattlePreparationPage._validate_with_selector( + current, + current, + selectors, + ) + assert BattlePreparationPage._validate_with_selector( + current, + current, + selectors, + {0}, + ) + def test_find_wrong_slots(self): current = ['X', 'B', 'Y', None, 'E', None] desired = ['A', 'B', 'C', None, None, None] @@ -969,7 +1369,7 @@ def test_fleet_change_tries_candidates_in_rule_order(self): def test_slot_failure_does_not_borrow_another_slot_candidates(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) current = [None] * 6 - names = ['契卡洛夫', '岛风', None, None, None, None] + occupied = [False] * 6 selectors: list[FleetSlotRule | None] = [ _candidate_rule('契卡洛夫', min_level=100), _candidate_rule('岛风', '黑潮', min_level=100), @@ -978,20 +1378,29 @@ def test_slot_failure_does_not_borrow_another_slot_candidates(self): None, None, ] + assigned = BattlePreparationPage._plan_target_options(selectors) + assert assigned is not None with ( patch.object( page, - '_change_single_ship', + '_try_select_option', side_effect=RuntimeError('未找到契卡洛夫'), - ) as change_ship, + ) as select_option, pytest.raises(RuntimeError, match='契卡洛夫'), ): - page._full_align(current, names, selectors) + page._align_member_set( + current, + occupied, + assigned, + selectors, + set(), + set(), + {}, + ) - assert change_ship.call_count == 1 - assert change_ship.call_args.args == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector'] == ( + select_option.assert_called_once_with( + 0, ShipSelector( name='契卡洛夫', min_level=100, @@ -999,28 +1408,159 @@ def test_slot_failure_does_not_borrow_another_slot_candidates(self): ), ) + def test_existing_primary_members_are_kept_until_final_reorder(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + current = ['X', 'A', 'C', 'E', 'Y', 'Z'] + occupied = [True] * 6 + selectors: list[FleetSlotRule | None] = list( + exact_fleet_rules(['A', 'B', 'C', 'D', 'E', 'F']), + ) + assigned = BattlePreparationPage._plan_target_options(selectors, current) + assert assigned is not None + selected: list[tuple[int, str]] = [] + + def select_option(slot: int, option: ShipSelector) -> _ShipSelection: + selected.append((slot, option.name)) + return _ShipSelection(option.name, option) + + with ( + patch.object(page, '_try_select_option', side_effect=select_option), + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), + ): + page._align_member_set( + current, + occupied, + assigned, + selectors, + set(), + set(), + {}, + ) + + assert selected == [(0, 'B'), (4, 'D'), (5, 'F')] + assert current == ['B', 'A', 'C', 'E', 'D', 'F'] + + with patch('autowsgr.ui.battle.fleet_change._change.time.sleep'): + page._reorder(current, ['A', 'B', 'C', 'D', 'E', 'F']) + + assert current == ['A', 'B', 'C', 'D', 'E', 'F'] + + def test_technical_selection_error_does_not_enable_fallback(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = FleetSlotRule( + primary=ShipSelector(name='A'), + candidates=(ShipSelector(name='B', relaxed_constraints=True),), + ) + selectors: list[FleetSlotRule | None] = [rule, None, None, None, None, None] + assigned = BattlePreparationPage._plan_target_options(selectors) + assert assigned is not None + unavailable: set[tuple[int, ShipSelector]] = set() + + with ( + patch.object( + page, + '_try_select_option', + side_effect=RuntimeError('控制器断开'), + ), + pytest.raises(RuntimeError, match='控制器断开'), + ): + page._align_member_set( + [None] * 6, + [False] * 6, + assigned, + selectors, + set(), + unavailable, + {}, + ) + + assert unavailable == set() + assert assigned[0] == rule.primary + def test_local_fix_replaces_before_removing(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) current = ['A', 'X', 'C', 'D', None, None] - desired = ['A', 'B', 'C', None, None, None] + occupied = [True, True, True, True, False, False] + selectors: list[FleetSlotRule | None] = [ + *exact_fleet_rules(['A', 'B', 'C']), + None, + None, + None, + ] + assigned = BattlePreparationPage._plan_target_options(selectors) + assert assigned is not None actions: list[str] = [] with ( patch.object( page, - '_replace_target', - side_effect=lambda *_args: actions.append('replace'), + '_try_select_option', + side_effect=lambda _slot, option: ( + actions.append('replace') or _ShipSelection(option.name, option) + ), ), patch.object( page, '_change_single_ship', side_effect=lambda *_args, **_kwargs: actions.append('remove'), ), + patch.object( + page, + 'detect_fleet_snapshot', + return_value=_snapshot(['A', 'B', 'C', None, None, None]), + ), patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), ): - page._local_fix(current, desired, [None] * 6) + page._local_fix( + current, + occupied, + assigned, + selectors, + set(), + set(), + {}, + ['A', 'B', 'C'], + ) + + assert actions[0] == 'replace' + assert actions[1:] == ['remove', 'remove'] + + def test_unknown_occupied_slot_is_not_treated_as_empty(self): + option = ShipSelector(name='A') + current = [None, None, None, None, None, None] + occupied = [True, False, False, False, False, False] + + assert ( + BattlePreparationPage._replacement_slot( + current, + occupied, + option, + set(), + None, + set(), + 0, + ) + == 1 + ) - assert actions == ['replace', 'remove'] + def test_unknown_slot_is_used_after_normal_selection_failed(self): + option = ShipSelector(name='A') + current = [None, 'X', None, None, None, None] + occupied = [True, True, False, False, False, False] + attempted = {(0, option, 2)} + + assert ( + BattlePreparationPage._replacement_slot( + current, + occupied, + option, + set(), + None, + attempted, + 0, + ) + == 0 + ) def test_reorder_moves_existing_ship(self): ctrl = MagicMock(spec=AndroidController) diff --git a/testing/vision/test_ocr.py b/testing/vision/test_ocr.py index 9368bef8..dd553cc1 100644 --- a/testing/vision/test_ocr.py +++ b/testing/vision/test_ocr.py @@ -11,6 +11,7 @@ SHIPNAME_GROUPS, SHIPNAMES, get_ship_name_variants, + normalize_ship_name, ship_name_identity, ) from autowsgr.vision import OCREngine, OCRResult, ShipNameMismatchError @@ -342,6 +343,25 @@ def teardown_method(self): set_user_ship_name_aliases({}) set_user_ship_name_corrections({}) + @pytest.mark.parametrize( + ('raw', 'expected'), + [ + (None, None), + ('', None), + (' 岛风 ', '岛风'), + ('岛风·改', '岛风'), + ('飞龙(苍青幻影)', '飞龙'), + ], + ) + def test_ship_name_normalization(self, raw: object, expected: str | None): + assert normalize_ship_name(raw) == expected + + def test_ship_name_normalization_resolves_registered_alias(self): + set_user_ship_name_aliases({'契卡洛夫': '85工程'}) + + assert normalize_ship_name(' 契卡洛夫·改 ') == '85工程' + assert ship_name_identity('契卡洛夫(自定义)') == ship_name_identity('85工程') + def test_only_confirmed_cjk_separator_is_corrected(self): assert apply_ship_patches('安德烈亚:多利亚') == '安德烈亚·多利亚' assert apply_ship_patches('鳟盹') == '鳞鲀' diff --git a/uv.lock b/uv.lock index 916e9da4..21877a62 100644 --- a/uv.lock +++ b/uv.lock @@ -83,7 +83,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "adbutils", specifier = ">=2.0,<3.0" }, - { name = "autowsgr-native", specifier = ">=0.2.0" }, + { name = "autowsgr-native", specifier = ">=0.3.0" }, { name = "av", specifier = ">=12.0" }, { name = "easyocr", specifier = ">=1.7.1" }, { name = "fastapi", specifier = ">=0.100.0" }, From e81f86b15399e838d55e3dda70a3c3efae4a4f26 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 4 Aug 2026 15:22:26 +0800 Subject: [PATCH 08/11] fix: complete backend runtime contract remediation --- autowsgr/scheduler/launcher.py | 13 +++++++++++-- autowsgr/server/routes/system.py | 2 +- testing/server/test_system_routes.py | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/autowsgr/scheduler/launcher.py b/autowsgr/scheduler/launcher.py index 97614891..4a33580c 100644 --- a/autowsgr/scheduler/launcher.py +++ b/autowsgr/scheduler/launcher.py @@ -25,6 +25,7 @@ from __future__ import annotations +import os from pathlib import Path from autowsgr.context import GameContext @@ -69,6 +70,8 @@ def __init__(self, config_path: str | Path | None = None) -> None: def load_config(self) -> UserConfig: """从 YAML 加载配置并初始化日志。 + 配置文件不存在时使用内置默认配置。 + 如果构造时未传入 ``config_path``,将由 :class:`ConfigManager` 自动检测当前目录下的 ``usersettings.yaml``;若也不存在则 使用内置默认配置。 @@ -82,7 +85,7 @@ def load_config(self) -> UserConfig: setup_logger( log_cfg.dir, log_cfg.level, - save_images=False, + save_images=os.getenv('AUTOWSGR_SAVE_IMAGES', '').lower() == 'true', channels=log_cfg.effective_channels or None, ) ch_summary = log_cfg.effective_channels @@ -136,7 +139,13 @@ def create_ocr(self) -> OCREngine: """根据配置创建 EasyOCR 引擎。""" cfg = self.config _log.info('[Launcher] 创建 EasyOCR 引擎') - self._ocr = EasyOCREngine.create(gpu=cfg.ocr.gpu, mirror=cfg.ocr.mirror) + 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) # 同步船池感知匹配置信度到 ocr 模块 from autowsgr.vision.ocr import set_ship_name_match_confidence from autowsgr.vision.ocr_rules import ( diff --git a/autowsgr/server/routes/system.py b/autowsgr/server/routes/system.py index 2918a222..4e9d45d7 100644 --- a/autowsgr/server/routes/system.py +++ b/autowsgr/server/routes/system.py @@ -44,7 +44,7 @@ async def system_start(request: SystemStartRequest) -> ApiResponse: config_path = request.config_path or 'usersettings.yaml' _log.info('[System] 正在启动, 配置: {}', config_path) - _main._ctx = launch(config_path) + _main._ctx = await asyncio.to_thread(launch, config_path=config_path) _log.info('[System] 启动成功') return ApiResponse(success=True, message='系统启动成功') diff --git a/testing/server/test_system_routes.py b/testing/server/test_system_routes.py index b0691b97..3251e6f1 100644 --- a/testing/server/test_system_routes.py +++ b/testing/server/test_system_routes.py @@ -105,7 +105,8 @@ def test_system_start_reports_launch_failure( """Launch errors leave the global context unpublished.""" scheduler_module = types.ModuleType('autowsgr.scheduler') - def launch(_config_path: str) -> object: + def launch(*, config_path: str) -> object: + assert config_path == 'usersettings.yaml' raise RuntimeError('launch failed') scheduler_module.launch = launch # type: ignore[attr-defined] From b9f6fb90811a0af2abae0a1755088bbecc4140db Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 4 Aug 2026 15:52:25 +0800 Subject: [PATCH 09/11] fix: preserve fleet and rule compatibility contracts --- autowsgr/combat/fleet.py | 69 ++++++++++++---------- autowsgr/server/schemas.py | 10 +++- autowsgr/types.py | 12 ++-- pyproject.toml | 2 +- testing/combat/test_combat.py | 32 +++------- testing/test_server_schemas.py | 45 ++++++++++---- testing/ui/battle_preparation/test_unit.py | 26 ++++---- uv.lock | 2 +- 8 files changed, 107 insertions(+), 91 deletions(-) diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py index 79921156..56bae795 100644 --- a/autowsgr/combat/fleet.py +++ b/autowsgr/combat/fleet.py @@ -28,14 +28,38 @@ 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, + 'cvl': ShipType.CVL, + 'av': ShipType.AV, + 'bb': ShipType.BB, + 'bbv': ShipType.BBV, + 'bc': ShipType.BC, + 'ca': ShipType.CA, + 'cav': ShipType.CAV, + 'clt': ShipType.CLT, + 'cl': ShipType.CL, + 'bm': ShipType.BM, + 'dd': ShipType.DD, + 'ssg': ShipType.SSG, + 'ss': ShipType.SS, + 'sc': ShipType.SC, + 'ap': ShipType.NAP, + 'asdg': ShipType.ASDG, + 'aadg': ShipType.AADG, + 'kp': ShipType.KP, + 'cg': ShipType.CG, + 'bbg': ShipType.BG, + 'bg': ShipType.CBG, + }, +) +"""native 0.3 舰种代码到 AutoWSGR 领域枚举的显式映射。""" + VESSEL_TYPE_TO_SHIP_TYPE: tuple[tuple[VesselType, ShipType], ...] = tuple( - ( - vessel_type.native, - ShipType[vessel_type.code.upper()], - ) + (vessel_type.native, _NATIVE_CODE_TO_SHIP_TYPE[vessel_type.code]) for vessel_type in FLEET_VESSEL_TYPES ) -"""native 0.3 普通舰种到同名 AutoWSGR 领域枚举的映射。""" for _native_type, _ship_type in VESSEL_TYPE_TO_SHIP_TYPE: if _native_type.as_chinese() != _ship_type.value: @@ -216,9 +240,10 @@ def _selector_from_mapping( if name is None: raise ValueError('name 不能为空') source = raw if inherited is None else inherited + inherited_search_name = inherited.get('search_name') if inherited is not None else None return ShipSelector( name=name, - search_name=_optional_text(raw.get('search_name')), + search_name=_optional_text(raw.get('search_name', inherited_search_name)), ship_types=parse_ship_type_codes(source.get('ship_type')), min_level=_optional_level(source, 'min_level'), max_level=_optional_level(source, 'max_level'), @@ -239,7 +264,7 @@ def fleet_slot_from_api(raw: str | Mapping[str, Any]) -> FleetSlotRule: if not isinstance(raw_candidates, Sequence) or isinstance(raw_candidates, str): raise TypeError('candidates 必须是规则对象列表') candidates = tuple( - _selector_from_mapping(candidate, relaxed=True) + _selector_from_mapping(candidate, relaxed=False) for candidate in raw_candidates if isinstance(candidate, Mapping) ) @@ -263,37 +288,17 @@ def fleet_slot_from_yaml(raw: object) -> FleetSlotRule: primary: ShipSelector | None = None if _optional_text(raw.get('name')) is not None: primary = _selector_from_mapping(raw, relaxed=False) - else: - primary_index = next( - ( - index - for index, candidate in enumerate(candidates) - if isinstance(candidate, str) and candidate.strip() - ), - None, - ) - if primary_index is not None: - primary_name = candidates.pop(primary_index) - primary = _selector_from_mapping( - { - 'name': primary_name, - 'search_name': raw.get('search_name'), - }, - relaxed=False, - inherited=raw, - ) - normalized_candidates: list[ShipSelector] = [] seen: set[str] = set() for candidate in candidates: if isinstance(candidate, str): selector = _selector_from_mapping( {'name': candidate}, - relaxed=True, + relaxed=False, inherited=raw, ) elif isinstance(candidate, Mapping): - selector = _selector_from_mapping(candidate, relaxed=True) + selector = _selector_from_mapping(candidate, relaxed=False) else: raise TypeError('candidates 只能包含舰名字符串或规则对象') if selector.name in seen: @@ -315,9 +320,11 @@ def fleet_presets_from_yaml(raw: object) -> tuple[FleetPreset, ...] | None: if not isinstance(raw_preset, Mapping): raise TypeError('fleet_presets 每一项必须是对象') name = _optional_text(raw_preset.get('name')) or '' - raw_slots = raw_preset.get('ships', []) + raw_slots = raw_preset.get('ships') if not isinstance(raw_slots, list): - raise TypeError('fleet_presets.ships 必须是列表') + raise ValueError('fleet_presets.ships 必须是非空列表') + if not raw_slots: + raise ValueError('fleet_presets 不能包含空 ships') presets.append( FleetPreset( name=name, diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index bb857baa..f783290d 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal +from typing import Any, Literal, TypeAlias from pydantic import BaseModel, Field, field_validator, model_validator @@ -44,6 +44,10 @@ class LogLevel(StrEnum): ERROR = 'ERROR' +RuleSpec: TypeAlias = tuple[str, Literal['retreat', 'detour'] | int] +"""HTTP rule item: condition expression plus retreat/detour/formation action.""" + + # ═══════════════════════════════════════════════════════════════════════════════ # 节点决策模型 # ═══════════════════════════════════════════════════════════════════════════════ @@ -68,11 +72,11 @@ class NodeDecisionRequest(BaseModel): default=True, description='迂回失败时是否 SL', ) - enemy_rules: list[str | list] | None = Field( + enemy_rules: list[RuleSpec] | None = Field( default=None, description='索敌规则', ) - enemy_formation_rules: list[str | list] | None = Field( + enemy_formation_rules: list[RuleSpec] | None = Field( default=None, description='敌方阵型规则', ) diff --git a/autowsgr/types.py b/autowsgr/types.py index 93227207..d07fddc7 100644 --- a/autowsgr/types.py +++ b/autowsgr/types.py @@ -331,13 +331,13 @@ class ShipType(StrEnum): SSG = '导潜' SS = '潜艇' SC = '炮潜' - AP = '补给' + NAP = '补给' ASDG = '导驱' AADG = '防驱' KP = '导巡' CG = '防巡' - BG = '大巡' - BBG = '导战' + CBG = '大巡' + BG = '导战' Other = '其他' @property @@ -359,13 +359,13 @@ def relative_position_in_destroy(self) -> tuple[float, float]: ShipType.SSG: (0.738, 0.379), ShipType.SS: (0.830, 0.379), ShipType.SC: (0.922, 0.379), - ShipType.AP: (0.555, 0.470), + ShipType.NAP: (0.555, 0.470), ShipType.ASDG: (0.646, 0.470), ShipType.AADG: (0.738, 0.470), ShipType.KP: (0.830, 0.470), ShipType.CG: (0.922, 0.470), - ShipType.BG: (0.555, 0.561), - ShipType.BBG: (0.646, 0.561), + ShipType.CBG: (0.555, 0.561), + ShipType.BG: (0.646, 0.561), ShipType.Other: (0.738, 0.561), } return _map[self] diff --git a/pyproject.toml b/pyproject.toml index 12916d1a..3f38d3d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "keyboard", "easyocr>=1.7.1", "adbutils>=2.0,<3.0", - "autowsgr_native>=0.3.0", + "autowsgr_native==0.3.0", "av>=12.0", "pydantic>=2.0,<3.0", # HTTP Server diff --git a/testing/combat/test_combat.py b/testing/combat/test_combat.py index fdc5d044..c1bc9103 100644 --- a/testing/combat/test_combat.py +++ b/testing/combat/test_combat.py @@ -455,7 +455,7 @@ def test_empty_presets_is_preserved(self): assert plan.fleet_presets == () def test_preset_content_is_normalized(self): - """旧字符串候选迁移为显式主选和完整备选规则。""" + """旧字符串候选保留为有序候选规则。""" plan = CombatPlan.from_dict( { 'fleet_presets': [ @@ -479,24 +479,9 @@ def test_preset_content_is_normalized(self): assert preset.name == '测试舰队' assert preset.slots[0] == FleetSlotRule(primary=ShipSelector(name='飞龙·改')) assert preset.slots[1] == FleetSlotRule( - primary=ShipSelector( - name='岛风', - ship_types=(ShipType.DD,), - min_level=100, - ), candidates=( - ShipSelector( - name='黑潮', - ship_types=(ShipType.DD,), - min_level=100, - relaxed_constraints=True, - ), - ShipSelector( - name='岛风', - ship_types=(ShipType.DD,), - min_level=100, - relaxed_constraints=True, - ), + ShipSelector(name='岛风', ship_types=(ShipType.DD,), min_level=100), + ShipSelector(name='黑潮', ship_types=(ShipType.DD,), min_level=100), ), ) @@ -548,14 +533,12 @@ def test_independent_candidate_rules_are_preserved(self): ship_types=(ShipType.SS,), min_level=90, max_level=105, - relaxed_constraints=True, ), ShipSelector( name='U-47', ship_types=(ShipType.SS,), min_level=100, max_level=110, - relaxed_constraints=True, ), ) @@ -591,14 +574,12 @@ def test_candidate_only_slots_are_preserved(self): ShipSelector( name='胡德', ship_types=(ShipType.BC,), - relaxed_constraints=True, ), ShipSelector( name='扶桑', ship_types=(ShipType.BB,), min_level=80, max_level=110, - relaxed_constraints=True, ), ) @@ -622,8 +603,8 @@ def test_slot_fields_are_converted_to_domain_model(self): ), ) - def test_legacy_primary_keeps_search_name(self): - """旧字符串主选迁移时保留顶层搜索名。""" + def test_legacy_candidate_only_keeps_search_name_on_each_candidate(self): + """旧字符串候选不提升主选,槽位搜索名不改变候选身份。""" plan = CombatPlan.from_dict( { 'fleet_presets': [ @@ -641,7 +622,8 @@ def test_legacy_primary_keeps_search_name(self): assert plan.fleet_presets is not None slot = plan.fleet_presets[0].slots[0] - assert slot.primary == ShipSelector(name='85工程', search_name='契卡洛夫') + assert slot.primary is None + assert [candidate.name for candidate in slot.candidates] == ['85工程', '岛风'] # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index 93a4fd5f..fb690a1a 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -85,7 +85,7 @@ def test_candidate_only_fleet_rule_is_valid(): slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) assert slot.primary is None assert [candidate.name for candidate in slot.candidates] == ['胡德', '扶桑'] - assert all(candidate.relaxed_constraints for candidate in slot.candidates) + assert all(not candidate.relaxed_constraints for candidate in slot.candidates) def test_empty_fleet_slot_is_rejected(): @@ -139,14 +139,14 @@ def test_invalid_candidate_ship_type_is_rejected(): ('code', 'expected'), [ ('aadg', (ShipType.AADG,)), - ('ap', (ShipType.AP,)), + ('ap', (ShipType.NAP,)), ('asdg', (ShipType.ASDG,)), ('av', (ShipType.AV,)), ('bb', (ShipType.BB,)), - ('bbg', (ShipType.BBG,)), + ('bbg', (ShipType.BG,)), ('bbv', (ShipType.BBV,)), ('bc', (ShipType.BC,)), - ('bg', (ShipType.BG,)), + ('bg', (ShipType.CBG,)), ('bm', (ShipType.BM,)), ('ca', (ShipType.CA,)), ('cav', (ShipType.CAV,)), @@ -178,14 +178,14 @@ def test_api_ship_type_code_maps_to_domain_enum( ('native_type', 'expected'), [ (VesselType.AADG, ShipType.AADG), - (VesselType.AP, ShipType.AP), + (VesselType.AP, ShipType.NAP), (VesselType.ASDG, ShipType.ASDG), (VesselType.AV, ShipType.AV), (VesselType.BB, ShipType.BB), - (VesselType.BBG, ShipType.BBG), + (VesselType.BBG, ShipType.BG), + (VesselType.BG, ShipType.CBG), (VesselType.BBV, ShipType.BBV), (VesselType.BC, ShipType.BC), - (VesselType.BG, ShipType.BG), (VesselType.BM, ShipType.BM), (VesselType.CA, ShipType.CA), (VesselType.CAV, ShipType.CAV), @@ -206,7 +206,6 @@ def test_native_vessel_type_maps_to_domain_enum( expected: ShipType, ): assert ship_type_from_native(native_type) is expected - assert native_type.as_english() == expected.name assert native_type.as_chinese() == expected.value @@ -232,8 +231,8 @@ def test_native_fleet_codes_are_complete(): 'aadg', 'kp', 'cg', - 'bg', 'bbg', + 'bg', } @@ -275,6 +274,26 @@ def test_yaml_and_api_candidate_only_rules_share_canonical_model(): assert selection.source is FleetSelectionSource.OVERRIDE_RULES +def test_legacy_candidate_only_does_not_promote_first_candidate(): + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + {'ships': [{'candidates': ['A', 'B'], 'ship_type': ['dd']}]}, + ], + }, + ) + slot = plan.fleet_presets[0].slots[0] + assert slot.primary is None + assert [candidate.name for candidate in slot.candidates] == ['A', 'B'] + + +def test_empty_fleet_preset_is_rejected(): + with pytest.raises((TypeError, ValueError), match='ships'): + CombatPlan.from_dict({'fleet_presets': [{}]}) + with pytest.raises(ValueError, match='不能包含空 ships'): + CombatPlan.from_dict({'fleet_presets': [{'ships': []}]}) + + @pytest.mark.parametrize( ('top_level_id', 'request_id', 'plan_id', 'expected'), [ @@ -305,7 +324,7 @@ def test_event_fleet_id_priority_is_resolved_at_server_boundary( def test_node_decision_request_keeps_yaml_supported_fields(): decision = NodeDecisionRequest.model_validate( { - 'enemy_rules': ['(BB > 0) => retreat'], + 'enemy_rules': [['BB > 0', 'retreat']], 'enemy_formation_rules': [['(line_ahead)', 'retreat']], 'SL_when_spot_enemy_fails': True, 'SL_when_enter_fight': True, @@ -313,8 +332,8 @@ def test_node_decision_request_keeps_yaml_supported_fields(): }, ) - assert decision.enemy_rules == ['(BB > 0) => retreat'] - assert decision.enemy_formation_rules == [['(line_ahead)', 'retreat']] + assert decision.enemy_rules == [('BB > 0', 'retreat')] + assert decision.enemy_formation_rules == [('(line_ahead)', 'retreat')] assert decision.SL_when_spot_enemy_fails is True assert decision.SL_when_enter_fight is True assert decision.formation_when_spot_enemy_fails == 3 @@ -326,7 +345,7 @@ def test_api_combat_plan_parses_event_entrance_and_node_fields(): chapter='H', map='1a', node_defaults=NodeDecisionRequest( - enemy_rules=['(BB > 0) => retreat'], + enemy_rules=[['BB > 0', 'retreat']], SL_when_spot_enemy_fails=True, formation_when_spot_enemy_fails=3, ), diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index 1f604cfb..720cb0be 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -701,7 +701,7 @@ def test_custom_name_search_accepts_standard_name_result(self): assert select_option.call_args == call( 1, - ShipSelector(name='契卡洛夫', relaxed_constraints=True), + ShipSelector(name='契卡洛夫'), ) change_ship.assert_called_once_with(0, None, slot_occupied=True) @@ -726,7 +726,11 @@ def move_ship(src: int, dst: int, current: list[str | None]) -> None: _snapshot(target_fleet), ], ), - patch.object(page, '_try_select_option') as select_option, + patch.object( + page, + '_try_select_option', + return_value=None, + ) as select_option, patch.object(page, '_change_single_ship') as change_ship, patch.object(page, '_circular_move', side_effect=move_ship) as circular_move, patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), @@ -940,14 +944,12 @@ def test_primary_and_candidates_keep_independent_rules(self): ship_types=(ShipType.BC,), min_level=90, max_level=105, - relaxed_constraints=True, ), ShipSelector( name='密苏里', ship_types=(ShipType.BB,), min_level=80, max_level=110, - relaxed_constraints=True, ), ) @@ -975,13 +977,11 @@ def test_candidate_only_rules_keep_order_and_relax_constraints(self): name='胡德', ship_types=(ShipType.BC,), min_level=90, - relaxed_constraints=True, ), ShipSelector( name='扶桑', ship_types=(ShipType.BB,), max_level=110, - relaxed_constraints=True, ), ) @@ -1026,7 +1026,7 @@ def test_existing_strict_primary_is_reselected_for_constraint_validation(self): ) change_ship.assert_not_called() - def test_existing_candidate_only_ship_keeps_relaxed_constraints(self): + def test_existing_candidate_only_ship_requires_its_constraints(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) rule = _rule( { @@ -1047,12 +1047,16 @@ def test_existing_candidate_only_ship_keeps_relaxed_constraints(self): 'detect_fleet_snapshot', return_value=_snapshot(current), ), - patch.object(page, '_try_select_option') as select_option, + patch.object( + page, + '_try_select_option', + return_value=_ShipSelection('胡德', rule.candidates[0]), + ) as select_option, patch.object(page, '_change_single_ship') as change_ship, ): assert page.change_fleet(None, [rule]) - select_option.assert_not_called() + select_option.assert_called_once_with(0, rule.candidates[0]) change_ship.assert_not_called() def test_existing_candidate_does_not_replace_available_strict_primary(self): @@ -1174,7 +1178,7 @@ def test_fallback_replans_all_unlocked_candidate_slots(self): None, ] - def test_same_name_fallback_keeps_exact_relaxed_rule(self): + def test_same_name_fallback_keeps_exact_candidate_rule(self): rule = _rule( { 'name': '密苏里', @@ -1193,7 +1197,7 @@ def test_same_name_fallback_keeps_exact_relaxed_rule(self): assert assigned is not None assert assigned[0] == ShipSelector( name='密苏里', - relaxed_constraints=True, + relaxed_constraints=False, ) def test_candidate_only_slots_use_backtracking(self): diff --git a/uv.lock b/uv.lock index 21877a62..929d37a6 100644 --- a/uv.lock +++ b/uv.lock @@ -83,7 +83,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "adbutils", specifier = ">=2.0,<3.0" }, - { name = "autowsgr-native", specifier = ">=0.3.0" }, + { name = "autowsgr-native", specifier = "==0.3.0" }, { name = "av", specifier = ">=12.0" }, { name = "easyocr", specifier = ">=1.7.1" }, { name = "fastapi", specifier = ">=0.100.0" }, From b5b8e635d5d86331dbcac7797524e17511b90e52 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 4 Aug 2026 16:22:31 +0800 Subject: [PATCH 10/11] fix: validate rule actions and preserve ship type aliases --- autowsgr/combat/fleet.py | 23 ++++++++++++++++++++--- autowsgr/server/schemas.py | 5 +++-- testing/test_server_schemas.py | 25 +++++++++++++++++++++---- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py index 56bae795..9bbbf805 100644 --- a/autowsgr/combat/fleet.py +++ b/autowsgr/combat/fleet.py @@ -94,7 +94,23 @@ def ship_type_from_native(vessel_type: VesselType) -> ShipType: ) """API 舰种代码到后端领域枚举的唯一映射。""" -ALLOWED_SHIP_TYPE_CODES = frozenset(SHIP_TYPE_BY_CODE) +LEGACY_SHIP_TYPE_ALIASES: Mapping[str, str] = MappingProxyType( + { + 'cf': 'cv', + 'cgaa': 'cg', + 'cbg': 'bg', + 'ddg': 'asdg', + 'ddgaa': 'aadg', + }, +) +"""旧版 API/GUI 舰种代码到 canonical code 的兼容映射。""" + +_ALL_SHIP_TYPE_CODES = { + *SHIP_TYPE_BY_CODE, + *LEGACY_SHIP_TYPE_ALIASES, +} + +ALLOWED_SHIP_TYPE_CODES = frozenset(_ALL_SHIP_TYPE_CODES) def parse_ship_type_codes(raw: object) -> tuple[ShipType, ...]: @@ -110,7 +126,8 @@ def parse_ship_type_codes(raw: object) -> tuple[ShipType, ...]: if not isinstance(value, str) or not value.strip(): raise ValueError('ship_type 必须是非空字符串列表') code = value.strip().lower() - ship_types = SHIP_TYPE_BY_CODE.get(code) + canonical_code = LEGACY_SHIP_TYPE_ALIASES.get(code, code) + ship_types = SHIP_TYPE_BY_CODE.get(canonical_code) if ship_types is None: allowed = ', '.join(sorted(ALLOWED_SHIP_TYPE_CODES)) raise ValueError(f'ship_type 不合法: {value!r}, 可选值: {allowed}') @@ -322,7 +339,7 @@ def fleet_presets_from_yaml(raw: object) -> tuple[FleetPreset, ...] | None: name = _optional_text(raw_preset.get('name')) or '' raw_slots = raw_preset.get('ships') if not isinstance(raw_slots, list): - raise ValueError('fleet_presets.ships 必须是非空列表') + raise TypeError('fleet_presets.ships 必须是非空列表') if not raw_slots: raise ValueError('fleet_presets 不能包含空 ships') presets.append( diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index f783290d..e4cab7cc 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal, TypeAlias +from typing import Annotated, Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator @@ -44,7 +44,8 @@ class LogLevel(StrEnum): ERROR = 'ERROR' -RuleSpec: TypeAlias = tuple[str, Literal['retreat', 'detour'] | int] +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.""" diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index fb690a1a..162539d7 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -241,10 +241,27 @@ def test_non_fleet_native_type_is_rejected(): ship_type_from_native(VesselType.Airfield) -@pytest.mark.parametrize('code', ['cf', 'cgaa', 'cbg', 'ddg', 'ddgaa']) -def test_noncanonical_ship_type_is_rejected(code: str): - with pytest.raises(ValidationError, match='ship_type 不合法'): - FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': [code]}) +@pytest.mark.parametrize( + ('code', 'expected'), + [ + ('cf', ShipType.CV), + ('cgaa', ShipType.CG), + ('cbg', ShipType.CBG), + ('ddg', ShipType.ASDG), + ('ddgaa', ShipType.AADG), + ], +) +def test_legacy_ship_type_aliases_are_accepted(code: str, expected: ShipType): + rule = FleetRuleRequest.model_validate({'name': '测试舰船', 'ship_type': [code]}) + slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) + assert slot.primary is not None + assert slot.primary.ship_types == (expected,) + + +@pytest.mark.parametrize('action', [0, 6, True]) +def test_invalid_rule_formation_action_is_rejected_at_http_boundary(action: object): + with pytest.raises(ValidationError): + NodeDecisionRequest.model_validate({'enemy_rules': [['BB > 0', action]]}) def test_yaml_and_api_candidate_only_rules_share_canonical_model(): From c4f9dfe3c7faffd12801874d2eed2d3dd59c965b Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 4 Aug 2026 16:29:55 +0800 Subject: [PATCH 11/11] test(server): cover fleet selection runner call chain --- testing/server/test_task_routes.py | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/testing/server/test_task_routes.py b/testing/server/test_task_routes.py index dfb14c0d..824831e4 100644 --- a/testing/server/test_task_routes.py +++ b/testing/server/test_task_routes.py @@ -4,6 +4,7 @@ import asyncio from dataclasses import dataclass, field +from types import SimpleNamespace from typing import TYPE_CHECKING, Any import pytest @@ -28,6 +29,7 @@ TaskStatusResponse, ) from autowsgr.types import ConditionFlag, ShipType +import autowsgr.ops.normal_fight as normal_fight_module if TYPE_CHECKING: @@ -426,3 +428,48 @@ def run_event_fight( asyncio.run(task._start_event_fight(object(), request)) assert captured[0].fleet_id == 5 + + +def test_normal_route_enters_real_runner_with_resolved_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP route reaches the public ops entry and constructs the real runner.""" + manager = _ExecutingTaskManager() + captured: list[ResolvedFleetSelection] = [] + + def run_for_times( + runner: object, + times: int, + *, + gap: float = 0.0, + **_kwargs: object, + ) -> list[CombatResult]: + assert times == 1 + assert gap == 0.0 + assert isinstance(runner, normal_fight_module.NormalFightRunner) + captured.append(runner._fleet_selection) + return [CombatResult(flag=ConditionFlag.OPERATION_SUCCESS)] + + monkeypatch.setattr(task, 'task_manager', manager) + monkeypatch.setattr(normal_fight_module.NormalFightRunner, 'run_for_times', run_for_times) + + request = NormalFightRequest( + plan=CombatPlanRequest( + fleet_id=4, + fleet_rules=[FleetRuleRequest(name='真实 runner 舰', ship_type=['kp'])], + ), + ) + + ctx = SimpleNamespace( + ctrl=None, + config=SimpleNamespace(dock_full_destroy=False, destroy_ship_types=None), + ) + response = asyncio.run(task._start_normal_fight(ctx, request)) + + assert response.success is True + assert manager.outcome is not None + assert manager.outcome.success is True + assert len(captured) == 1 + assert captured[0].source is FleetSelectionSource.OVERRIDE_RULES + assert captured[0].fleet_id == 4 + assert captured[0].primary_names == ['真实 runner 舰']