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..9bbbf805 --- /dev/null +++ b/autowsgr/combat/fleet.py @@ -0,0 +1,401 @@ +"""舰队规则领域模型和入口转换。 + +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.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 + + +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, _NATIVE_CODE_TO_SHIP_TYPE[vessel_type.code]) + for vessel_type in FLEET_VESSEL_TYPES +) + +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( + { + **{ + 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), + }, +) +"""API 舰种代码到后端领域枚举的唯一映射。""" + +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, ...]: + """校验舰种缩写并转换为去重后的领域枚举。""" + 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() + 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}') + 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 + 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', 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'), + 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=False) + 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) + normalized_candidates: list[ShipSelector] = [] + seen: set[str] = set() + for candidate in candidates: + if isinstance(candidate, str): + selector = _selector_from_mapping( + {'name': candidate}, + relaxed=False, + inherited=raw, + ) + elif isinstance(candidate, Mapping): + selector = _selector_from_mapping(candidate, relaxed=False) + 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 必须是非空列表') + if not raw_slots: + raise ValueError('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 0f2f4b25..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, @@ -236,6 +237,8 @@ class CombatPlan: 出征舰队编号。 fleet: 舰队成员名单(换船用)。 + fleet_presets: + GUI 整理后的舰队预设列表。 repair_mode: 修理策略。 fight_condition: @@ -259,6 +262,7 @@ class CombatPlan: """ fleet_id: int = 1 fleet: list[str] | 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) @@ -320,6 +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 = fleet_presets_from_yaml(data.get('fleet_presets')) fight_condition = FightCondition(data.get('fight_condition', 4)) selected_nodes = data.get('selected_nodes', []) @@ -360,6 +365,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/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/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 a36971d3..09d058c6 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,16 +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 + self._fleet_selection = fleet_selection + self._fleet_id = fleet_selection.fleet_id # 从 config 读取拆船配置 self._dock_full_destroy = ctx.config.dock_full_destroy @@ -91,35 +95,6 @@ def __init__( self._ship_acquired_count: int | None = None self._fleet_ships: list[Ship] | None = 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 - - candidates = None - if isinstance(slot, dict): - candidates = slot.get('candidates') - else: - candidates = getattr(slot, 'candidates', None) - - if isinstance(candidates, list) and len(candidates) > 0: - names.append(_normalize_name(candidates[0])) - continue - names.append(None) - return names - # ── 公共接口 ── def run(self) -> CombatResult: @@ -135,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. 进入战斗地图 @@ -372,20 +346,23 @@ 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: + resolved_ship_names = page.last_changed_fleet + 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) + resolved_ship_names = page.last_changed_fleet # 补给 page.apply_supply() @@ -411,11 +388,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) # 出征 @@ -508,16 +481,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) @@ -528,8 +506,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/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/__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/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/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 48ceb3e1..e4cab7cc 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -3,34 +3,11 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Literal +from typing import Annotated, Any, Literal 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 # ═══════════════════════════════════════════════════════════════════════════════ @@ -67,6 +44,11 @@ class LogLevel(StrEnum): ERROR = 'ERROR' +type FormationAction = Annotated[int, Field(strict=True, ge=1, le=5)] +type RuleSpec = tuple[str, Literal['retreat', 'detour'] | FormationAction] +"""HTTP rule item: condition expression plus retreat/detour/formation action.""" + + # ═══════════════════════════════════════════════════════════════════════════════ # 节点决策模型 # ═══════════════════════════════════════════════════════════════════════════════ @@ -83,48 +65,88 @@ class NodeDecisionRequest(BaseModel): description='停止前进条件 (6个位置)', ) detour: bool = Field(default=False, description='是否迂回') - enemy_rules: list[list[str]] | None = Field( + long_missile_support: bool = Field( + default=False, + description='是否开启远程导弹支援', + ) + SL_when_detour_fails: bool = Field( + default=True, + description='迂回失败时是否 SL', + ) + enemy_rules: list[RuleSpec] | None = Field( default=None, description='索敌规则', ) + enemy_formation_rules: list[RuleSpec] | 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'} -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,42 @@ 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='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..6488e50b 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,29 +147,72 @@ 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 - - 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, + from autowsgr.combat.plan import parse_map_value + from autowsgr.types import RepairMode + + 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( 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], 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, + ) + + +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 b830cbbd..f2014b79 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -1,36 +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 typing import TYPE_CHECKING, TypedDict +from dataclasses import dataclass +from functools import cache +from typing import TYPE_CHECKING -from autowsgr.constants import ship_name_identity +from autowsgr.combat.fleet import FleetSlotRule, ShipSelector +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') @@ -41,24 +46,13 @@ # 等待选船页面出现的超时 (秒) _CHOOSE_PAGE_TIMEOUT: float = 5.0 -# 舰名尾部别名后缀,如“(苍青幻影)” -_SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$') - - -# 描述一个槽位可以使用的舰名和筛选条件。 -class FleetSlotSelector(TypedDict, total=False): - """编队槽位规则。""" - - name: str - candidates: list[str] - search_name: str - ship_type: str - min_level: int - max_level: int +@dataclass(frozen=True, slots=True) +class _ShipSelection: + """选船页实际命中的舰名和精确规则。""" -# 一个槽位可以是固定舰名、带条件的规则或空槽。 -FleetSlotInput = str | FleetSlotSelector | None + name: str | None + option: ShipSelector | None # 为普通出征和决战准备页提供同一套智能换船流程。 @@ -67,84 +61,123 @@ 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( # noqa: PLR0912 + def change_fleet( self, fleet_id: int | None, - ship_names: Sequence[FleetSlotInput], + 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[dict | 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: - # candidates 按 YAML 中的填写顺序保存优选和备选。 - candidates = selector.get('candidates', []) - if isinstance(candidates, list) and len(candidates) > 0: - names.append(self._normalize_ship_name(candidates[0])) - else: - names.append(None) - else: - names.append(None) - - # 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 - # 一队最后一艘船不能移除,因此槽位 0 必须有目标舰船。 - 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() + 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() + 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 # 仍有重试次数时,等待页面稳定后进入下一轮局部修正。 @@ -155,6 +188,9 @@ def change_fleet( # noqa: PLR0912 _MAX_SET_RETRIES + 1, ) time.sleep(0.5) + snapshot = self.detect_fleet_snapshot(expected_names=names) + current = snapshot.names + occupied = snapshot.occupied # 所有重试都失败时,记录当前舰队并退出。 else: @@ -166,123 +202,259 @@ def change_fleet( # noqa: PLR0912 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, ...]] = [] - # normalized 依次去掉空格、“·改”和尾部括号别名。 - normalized = str(value).strip() - normalized = normalized.removesuffix('·改') - normalized = _SHIP_ALIAS_SUFFIX_RE.sub('', normalized) - normalized = normalized.strip() - return normalized or 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 + + 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 _extract_selector(cls, slot: object | None) -> dict | None: - """返回选船页面可以直接使用的槽位规则。""" - # 固定舰名和空槽没有额外选船规则。 - if slot is None or isinstance(slot, str): - return None + 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 + ] - # 字典槽位直接读取 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] = [] + 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) - # 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) + @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) - # 没有舰名候选时无法形成有效选船规则。 - if not candidates: - return None + @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 + def _slot_options( + cls, + name: str | None, + selector: FleetSlotRule | None, + ) -> list[ShipSelector]: + 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 = ship_name_identity(normalized_name) + options.sort( + key=lambda option: ship_name_identity(option.name) != target_identity, + ) + return options - # 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() - if isinstance(raw_min, int) and raw_min > 0: - selector['min_level'] = raw_min - if isinstance(raw_max, int) and raw_max > 0: - selector['max_level'] = raw_max - return selector - - # 按“已分配舰名优先、原候选随后”的顺序生成本槽候选列表。 @classmethod - def _slot_candidates(cls, name: str | None, selector: dict | None) -> list[str]: - out: list[str] = [] + def _slot_candidates( + cls, + name: str | None, + selector: FleetSlotRule | None, + ) -> list[str]: + """返回本槽按尝试顺序排列的标准舰名。""" + candidates: list[str] = [] seen: set[str] = set() - 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 + for option in cls._slot_options(name, selector): + 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( cls, names: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotRule | None], ) -> list[str | None] | None: """为每个非空槽位分配唯一舰名,候选重叠时按优先级回溯。""" # options 保存六个槽位各自按优先级排列的候选舰名。 @@ -300,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 @@ -315,10 +487,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 @@ -328,7 +500,33 @@ def _matches_search_name(cls, current_name: str | None, raw_search_name: object) 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( + cls, + name: str | None, + selector: FleetSlotRule | None, + ) -> ShipSelector | None: + """返回与实际舰名对应的独立规则。""" + identity = ship_name_identity(name) + return next( + ( + option + for option in cls._slot_options(name, selector) + 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 @@ -336,39 +534,34 @@ def _select_available_candidate( cls, current: list[str | None], name: str | None, - selector: dict | None, + selector: FleetSlotRule | None, *, slot_to_replace: int | None = None, - ) -> tuple[str | None, dict | None]: + ) -> tuple[str | None, tuple[ShipSelector, ...] | 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) + ship_name_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 ship_name_identity(option.name) not in occupied ] if len(available) == 0: return None, None - chosen = available[0] - if selector is None: - return chosen, None - - # narrowed_selector 只把未占用候选交给选船页面。 - narrowed_selector = dict(selector) - narrowed_selector['candidates'] = available - return chosen, narrowed_selector + chosen = normalize_ship_name(available[0].name) + # 选船页面按顺序尝试未占用规则,各备选使用自己的约束。 + return chosen, tuple(available) # 将当前舰队成员与目标槽位一对一匹配,找出可以直接保留的舰船。 @classmethod @@ -376,7 +569,8 @@ def _match_existing_members( cls, current: list[str | None], desired: list[str | None], - selectors: list[dict | None], + selectors: list[FleetSlotRule | None], + verified_slots: set[int] | frozenset[int] = frozenset(), ) -> tuple[list[bool], set[int]]: """在当前舰队与目标槽位之间做一对一匹配。 @@ -396,8 +590,11 @@ def _match_existing_members( # 判断一艘当前舰船能否满足指定目标槽位。 def matches(slot: int, ship: str | None) -> bool: selector = selectors[slot] - 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 = cls._option_for_name(desired[slot], selector) + 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) ) # 第一轮优先保留已经位于正确槽位的舰船。 @@ -432,22 +629,23 @@ def _slot_matches( cls, current_name: str | None, target: str | None, - selector: dict | 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) - 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 + 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, ) # 验证当前六个槽位是否完整满足目标,并拒绝队内同名舰。 @@ -456,13 +654,22 @@ def _validate_with_selector( cls, current: list[str | None], desired: list[str | None], - selectors: list[dict | 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 @@ -470,135 +677,301 @@ def _find_wrong_slots( cls, current: list[str | None], names: list[str | None], - selectors: list[dict | 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, + ) + ] - # 为一个目标槽位选择舰船,并同步更新当前舰队和目标舰名。 - def _replace_target( - self, - current: list[str | None], - names: list[str | None], - selectors: list[dict | None], - target_slot: int, - ship_slot: int | None = None, - ) -> 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( - current, - target, - selectors[target_slot], - slot_to_replace=slot, - ) - # 本槽所有候选都被占用时,无法组成目标舰队。 - if selected_name is None: - raise RuntimeError(f'目标槽位 {target_slot} 没有未被占用的候选舰船') + @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) - _log.info( - "[准备页] 更换槽位 {} <- '{}' (原: '{}')", - slot, - selected_name, - current[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, + ), ) - selected = self._change_single_ship( - slot, - selected_name, - selector=selected_selector, - slot_occupied=current[slot] is not None, + + @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] + + 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, ) - actual = selected if selected is not None else selected_name - current[slot] = actual - names[target_slot] = actual - time.sleep(0.3) - # 首次调整时完成成员复用、缺员补充、多余成员移除和压缩后补位。 - def _full_align( + def _align_member_set( self, current: list[str | None], - names: list[str | None], - selectors: list[dict | 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], ) -> 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: + """只处理成员集合;不拖拽最终顺序,也不删除多余舰船。""" + 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 - if i in matched_slots: + + _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 - # 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() - 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) + if not self._option_matches_name(selection.name, option): + raise RuntimeError( + f'选船结果 {selection.name!r} 与规则 {option.name!r} 不一致', + ) - current_count = sum(1 for v in current if v is not None) - if current_count >= target_count: - break + 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) - # OCR 验证失败后,只替换或移除不符合目标的槽位。 - def _local_fix( + raise RuntimeError('成员集合调整次数超过安全上限') + + def _remove_extra_members( self, current: list[str | None], - names: list[str | None], - selectors: list[dict | None], + occupied: list[bool], + assigned: Sequence[ShipSelector | None], + verified_slots: set[int], ) -> None: - """只修正本轮识别出的错误槽位。""" - # wrong 保存所有需要替换、补充或移除的槽位。 - wrong = self._find_wrong_slots(current, names, selectors) - if not wrong: - return - - _log.info('[准备页] 局部修正: 错误槽位 {}', wrong) + """目标成员齐全后,从后往前删除所有多余或未知成员。""" + protected, _, _ = self._assignment_locations( + current, + occupied, + assigned, + verified_slots, + ) + 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) - # 先完成替换/补员,再移除多余舰船。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] + 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 - # Step 1:先替换和补船,避免一队在移除时变成空队。 - for i in replacement_slots: - self._replace_target(current, names, selectors, i) + # 首次调整时完成成员复用、缺员补充、多余成员移除和压缩后补位。 + def _full_align( + self, + current: 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: + """首次将当前舰队调整为目标成员集合。""" + 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, + ) - # 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) + # OCR 验证失败后,只修正成员集合,不在此阶段拖拽排序。 + def _local_fix( + self, + current: 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: + """重试时重新补齐成员,再清理多余成员。""" + 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( @@ -611,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: @@ -656,24 +1029,11 @@ def _circular_move( current.insert(dst, ship) time.sleep(0.5) - # 打开指定槽位的选船页面,完成单艘舰船的选择或移除。 - def _change_single_ship( - self, - slot: int, - name: str | None, - *, - selector: dict | None = None, - slot_occupied: bool = True, - ) -> str | None: - """返回选船页面实际选中的舰名。""" + def _open_choose_page(self, slot: int) -> ChooseShipPage: + """打开指定物理槽位的选船页面。""" 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, @@ -682,10 +1042,68 @@ def _change_single_ship( source='编队', target='编队选船', ) - # choose_page 负责根据舰名、舰种和等级条件执行实际选船。 - choose_page = ChooseShipPage(self._ctx) - return choose_page.change_single_ship( - name, + 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, - selector=selector, ) + if selected is None: + self._cancel_choose_page() + return _ShipSelection(name=selected, option=option) + + # 打开指定槽位的选船页面,完成单艘舰船的选择或移除。 + def _change_single_ship( + self, + slot: int, + name: str | None, + *, + selector: Sequence[ShipSelector] | None = None, + slot_occupied: bool = True, + ) -> str | None: + """返回选船页面实际选中的舰名。""" + # 目标为空且当前槽位也为空时,不需要打开选船页面。 + if name is None and not slot_occupied: + return None + + # FleetChange 决定候选顺序,页面每次只执行一条明确规则。 + choose_page = self._open_choose_page(slot) + 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] + 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 45625f2f..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() @@ -120,6 +142,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 +163,17 @@ 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, + 'raw': raw_text, + 'patched': text, + 'matched': matched, + } + ) # 完整船池和目标上下文都无法识别时跳过该文字。 if matched is None: _log.debug("[准备页] OCR '{}' -> 无匹配, 跳过", raw_text) @@ -147,9 +181,34 @@ def detect_fleet( ships[slot] = matched _log.debug("[准备页] 槽位 {} OCR -> '{}'", slot, matched) + _log.info( + '[准备页] 编队 OCR 识别: {}', + recognized_ocr, + ) _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 ed77e354..7b607707 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -16,8 +16,9 @@ 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 ( MatchStrategy, PixelChecker, @@ -25,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 @@ -34,6 +34,7 @@ if TYPE_CHECKING: import numpy as np + from autowsgr.combat.fleet import ShipSelector from autowsgr.context import GameContext @@ -60,29 +61,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,41 +175,32 @@ def click_remove(self) -> None: _log.debug('[UI] 选船 → 移除舰船') self._ctrl.click(*CLICK_REMOVE_SHIP) - def change_single_ship( # noqa: C901, PLR0912 + 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: - 可选规则,支持 ``candidates`` / ``search_name`` / - ``ship_type`` / ``min_level`` / ``max_level``。 - 其中 ``search_name`` 用于指定搜索框关键字(仅在 - ``use_search=True`` 且界面存在搜索框时生效), - ``candidates`` 用于限定允许点击的舰船名集合, - ``ship_type`` 用于按舰种筛选同名舰船, - ``min_level`` / ``max_level`` 用于按等级范围筛选。 Returns ------- str | None 实际选中的舰船名;移除操作返回 ``None``。 """ - if name is None: + if selector is None: self.click_remove() self._wait_leave_current_page() return None @@ -240,81 +209,23 @@ 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: + 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( - 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) - if use_search: - self.ensure_search_box() - self.input_ship_name(search_candidate) - self.ensure_dismiss_keyboard() - matched = self._click_ship_in_list( - candidate, - ship_type=ship_type, - min_level=min_level, - max_level=max_level, - ) - 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})' - - _log.error( - '[UI] 未在选船列表中找到可用候选: {}{}{}', - candidates, - level_hint, - ship_type_hint, + 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}{level_hint}{ship_type_hint}') + if matched is not None: + self._wait_leave_current_page() + return matched @staticmethod def _normalize_hit_entry(hit: object) -> tuple[str, float, float, float]: @@ -363,13 +274,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: tuple[ShipType, ...] | None = None, min_level: int | None = None, max_level: int | None = None, + relaxed_constraints: bool = False, ) -> str | None: """在选船列表页使用 DLL 定位 + OCR 识别舰船名并点击目标。 @@ -380,6 +292,9 @@ def _click_ship_in_list( # noqa: PLR0912 name: 目标舰船名。 匹配时会先做舰名归一化(如去除“·改”与尾部括号别名)后再比较。 + relaxed_constraints: + 备选舰船使用。舰名命中后只尝试一次等级和舰种校验, + 约束识别失败或不匹配时仍按舰名选择。 Returns ------- @@ -405,16 +320,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 = [] @@ -423,12 +347,16 @@ def _click_ship_in_list( # noqa: 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 @@ -447,7 +375,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 +392,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})", @@ -495,7 +425,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 @@ -523,42 +453,34 @@ 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) -> bool: - if detected is None: - return False - rule = expected.strip().lower() - if rule == 'ss_or_ssg': - return detected in {'ss', 'ssg'} - return detected == rule + def _is_ship_type_in_rule( + detected: ShipType | None, + expected: tuple[ShipType, ...], + ) -> bool: + return detected is not None and detected in expected @staticmethod 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/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/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/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/pyproject.toml b/pyproject.toml index 7890cd22..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.2.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 f2113694..c1bc9103 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: @@ -433,6 +434,198 @@ 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 is not None + preset = plan.fleet_presets[0] + assert preset.name == '测试舰队' + assert preset.slots[0] == FleetSlotRule(primary=ShipSelector(name='飞龙·改')) + assert preset.slots[1] == FleetSlotRule( + candidates=( + ShipSelector(name='岛风', ship_types=(ShipType.DD,), min_level=100), + ShipSelector(name='黑潮', ship_types=(ShipType.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 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, + ), + ShipSelector( + name='U-47', + ship_types=(ShipType.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 is not None + slot = plan.fleet_presets[0].slots[0] + assert slot.primary is None + assert slot.candidates == ( + ShipSelector( + name='胡德', + ship_types=(ShipType.BC,), + ), + ShipSelector( + name='扶桑', + ship_types=(ShipType.BB,), + min_level=80, + max_level=110, + ), + ) + + def test_slot_fields_are_converted_to_domain_model(self): + """解析阶段把槽位字段转换成 canonical model。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + '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_legacy_candidate_only_keeps_search_name_on_each_candidate(self): + """旧字符串候选不提升主选,槽位搜索名不改变候选身份。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'ships': [ + { + 'search_name': '契卡洛夫', + 'candidates': ['85工程', '岛风'], + }, + ], + }, + ], + }, + ) + + assert plan.fleet_presets is not None + slot = plan.fleet_presets[0].slots[0] + assert slot.primary is None + assert [candidate.name for candidate in slot.candidates] == ['85工程', '岛风'] + + # ═══════════════════════════════════════════════════════════════════════════════ # actions.py 测试 # ═══════════════════════════════════════════════════════════════════════════════ 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/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..298aa469 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 = [] @@ -200,6 +201,7 @@ def main() -> None: # 导航到出征地图页 → 选择地图 → 进入准备页 result = runner.run() + results.append(result) logger.info( ' 战斗结果: {} 血量={}', 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 e128dd5a..9afeacea 100644 --- a/testing/ops/test_normal_fight_unit.py +++ b/testing/ops/test_normal_fight_unit.py @@ -10,9 +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: @@ -35,12 +44,240 @@ 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, + }, + ], + }, + ) + + selection = resolve_fleet_selection(plan) + runner = NormalFightRunner(_make_ctx(), plan, selection) + + 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( + { + 'fleet_presets': [ + { + 'name': '计划编队', + 'ships': [{'name': 'U-47'}], + }, + ], + }, + ) + override = (FleetSlotRule(primary=ShipSelector(name='岛风')),) + selection = resolve_fleet_selection(plan, slot_rules=override) + + runner = NormalFightRunner(_make_ctx(), plan, selection) + + 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 = ( + 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( + { + 'fleet': ['飞龙'], + 'fleet_presets': [{'ships': [{'name': 'U-47'}]}], + }, + ) + + 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 _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。""" 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' @@ -48,21 +285,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 @@ -76,7 +313,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' @@ -85,7 +322,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/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] diff --git a/testing/server/test_task_routes.py b/testing/server/test_task_routes.py index 3265d86a..824831e4 100644 --- a/testing/server/test_task_routes.py +++ b/testing/server/test_task_routes.py @@ -4,24 +4,38 @@ import asyncio from dataclasses import dataclass, field +from types import SimpleNamespace from typing import TYPE_CHECKING, Any 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 +import autowsgr.ops.normal_fight as normal_fight_module + + +if TYPE_CHECKING: + from pathlib import Path + + from autowsgr.server.task_manager import TaskOutcome if TYPE_CHECKING: @@ -38,26 +52,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 +297,179 @@ 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 + + +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 舰'] diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py new file mode 100644 index 00000000..162539d7 --- /dev/null +++ b/testing/test_server_schemas.py @@ -0,0 +1,410 @@ +"""后端编队请求契约的定向测试。""" + +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, + 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(): + 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}, + ], + } + 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(not candidate.relaxed_constraints for candidate in slot.candidates) + + +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_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(): + with pytest.raises(ValidationError, match='ship_type 不合法'): + FleetRuleRequest.model_validate( + { + 'name': '岛风', + 'candidates': [ + { + 'name': '雪风', + 'ship_type': ['invalid'], + }, + ], + }, + ) + + +@pytest.mark.parametrize( + ('code', 'expected'), + [ + ('aadg', (ShipType.AADG,)), + ('ap', (ShipType.NAP,)), + ('asdg', (ShipType.ASDG,)), + ('av', (ShipType.AV,)), + ('bb', (ShipType.BB,)), + ('bbg', (ShipType.BG,)), + ('bbv', (ShipType.BBV,)), + ('bc', (ShipType.BC,)), + ('bg', (ShipType.CBG,)), + ('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,)), + ('ss', (ShipType.SS,)), + ('ssg', (ShipType.SSG,)), + ('ss_or_ssg', (ShipType.SS, ShipType.SSG)), + ], +) +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 + + +@pytest.mark.parametrize( + ('native_type', 'expected'), + [ + (VesselType.AADG, ShipType.AADG), + (VesselType.AP, ShipType.NAP), + (VesselType.ASDG, ShipType.ASDG), + (VesselType.AV, ShipType.AV), + (VesselType.BB, ShipType.BB), + (VesselType.BBG, ShipType.BG), + (VesselType.BG, ShipType.CBG), + (VesselType.BBV, ShipType.BBV), + (VesselType.BC, ShipType.BC), + (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_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', + 'bbg', + 'bg', + } + + +def test_non_fleet_native_type_is_rejected(): + with pytest.raises(ValueError, match='不支持的 native 舰种'): + ship_type_from_native(VesselType.Airfield) + + +@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(): + """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 + + +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'), + [ + (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'].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 f62315a2..720cb0be 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -3,14 +3,23 @@ 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.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 ShipDamageState, ShipType from autowsgr.ui.battle.base import PAGE_SIGNATURE from autowsgr.ui.battle.constants import ( AUTO_SUPPLY_PROBE, @@ -21,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, @@ -65,6 +76,40 @@ 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 _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] @@ -377,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() @@ -387,14 +538,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], + ) # ───────────────────────────────────────────── @@ -434,6 +603,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)), @@ -461,7 +658,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'])) # ───────────────────────────────────────────── @@ -475,21 +672,38 @@ 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(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']['candidates'] == ['契卡洛夫'] + assert select_option.call_args == call( + 1, + ShipSelector(name='契卡洛夫'), + ) + 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))) @@ -504,65 +718,125 @@ 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', + 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'), ): - assert page.change_fleet(1, [{'candidates': ['契卡洛夫']}, '岛风']) + assert page.change_fleet( + 1, + [ + _rule({'candidates': [{'name': '契卡洛夫'}]}), + *exact_fleet_rules(['岛风']), + ], + ) + select_option.assert_not_called() change_ship.assert_not_called() assert circular_move.call_args.args[:2] == (1, 0) - def test_first_fleet_replaces_before_removing_extra_ship(self): - """1 队从 AB 改为 C 时,先替换槽位 0,再移除 B。""" + def test_candidate_only_reuses_existing_nonpreferred_candidate(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) - fleet_a_b = ['A', 'B', None, None, None, None] - fleet_c = ['C', None, None, None, None, None] + 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, '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(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(1, ['C']) + assert page.change_fleet( + None, + [ + _candidate_rule('胡德', '扶桑'), + *exact_fleet_rules(['岛风']), + ], + ) - 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) + 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_slot_zero_cannot_be_empty(self): + def test_first_fleet_replaces_before_removing_extra_ship(self): + """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), - pytest.raises(ValueError, match='1 队槽位 0 不能为空'), + patch.object( + page, + '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'), ): - page.change_fleet(1, [None, 'B']) + assert page.change_fleet(1, exact_fleet_rules(['C'])) + + assert actions == [ + ('select', 2, 'C'), + ('remove', 1, None), + ('remove', 0, None), + ] def test_first_fleet_cannot_be_empty(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) 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, []) + page.change_fleet(1, ()) detect.assert_not_called() @@ -570,16 +844,20 @@ 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: - assert page.change_fleet(None, [*target, 'G']) + 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: - assert not page.change_fleet(None, ['A', 'A']) + with patch.object(page, 'detect_fleet_snapshot') as detect: + assert not page.change_fleet(None, exact_fleet_rules(['A', 'A'])) detect.assert_not_called() @@ -588,16 +866,32 @@ 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'), 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 + 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: @@ -612,31 +906,323 @@ 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_name_and_candidates_form_one_slot_rule(self): - selector = BattlePreparationPage._extract_selector( + def test_primary_and_candidates_keep_independent_rules(self): + selector = _rule( { '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, - } + 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, + ), + ShipSelector( + name='密苏里', + ship_types=(ShipType.BB,), + min_level=80, + max_level=110, + ), + ) + + def test_candidate_only_rules_keep_order_and_relax_constraints(self): + rule = _rule( + { + 'candidates': [ + { + 'name': '胡德', + 'ship_type': ['BC'], + 'min_level': 90, + }, + { + 'name': '扶桑', + 'ship_type': ['BB'], + 'max_level': 110, + }, + ], + }, + ) + + assert rule.primary is None + assert rule.candidates == ( + ShipSelector( + name='胡德', + ship_types=(ShipType.BC,), + min_level=90, + ), + ShipSelector( + name='扶桑', + ship_types=(ShipType.BB,), + max_level=110, + ), + ) + + def test_existing_strict_primary_is_reselected_for_constraint_validation(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] + option = rule.primary + assert option 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('密苏里', 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_requires_its_constraints(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = _rule( + { + 'candidates': [ + { + 'name': '胡德', + 'ship_type': ['BC'], + 'min_level': 90, + }, + ], + }, + ) + current = ['胡德', None, None, None, None, None] + + with ( + patch.object( + page, + 'detect_fleet_snapshot', + return_value=_snapshot(current), + ), + 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_called_once_with(0, rule.candidates[0]) + 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_candidate_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=False, + ) + + def test_candidate_only_slots_use_backtracking(self): + selectors = [ + _candidate_rule('胡德', '扶桑'), + _candidate_rule('胡德'), + None, + None, + None, + None, + ] + names = [ + selector.preferred_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] = [ - {'candidates': ['A', 'B']}, - {'candidates': ['A']}, + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('A', 'B'), + _candidate_rule('A'), None, None, None, @@ -654,9 +1240,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, @@ -675,18 +1261,17 @@ 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['candidates'] == ['雪风'] + 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, ) @@ -696,9 +1281,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( @@ -719,6 +1304,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] @@ -731,55 +1345,226 @@ 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}, + occupied = [False] * 6 + selectors: list[FleetSlotRule | None] = [ + _candidate_rule('契卡洛夫', min_level=100), + _candidate_rule('岛风', '黑潮', min_level=100), None, None, 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(), + {}, + ) + + select_option.assert_called_once_with( + 0, + ShipSelector( + name='契卡洛夫', + min_level=100, + relaxed_constraints=True, + ), + ) + + 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() - assert change_ship.call_count == 1 - assert change_ship.call_args.args == (0, '契卡洛夫') - assert change_ship.call_args.kwargs['selector']['candidates'] == ['契卡洛夫'] + 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 == ['replace', 'remove'] + 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 + ) + + 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/ui/test_choose_ship_page.py b/testing/ui/test_choose_ship_page.py index 800fcee7..68476f54 100644 --- a/testing/ui/test_choose_ship_page.py +++ b/testing/ui/test_choose_ship_page.py @@ -1,6 +1,12 @@ """测试选船页的舰名比较逻辑。""" +from types import SimpleNamespace +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 from autowsgr.vision.ocr_rules import set_user_ship_name_aliases @@ -34,3 +40,161 @@ 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_single_rule_uses_its_own_constraints(self): + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) + page = ChooseShipPage(ctx) + 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'), + patch.object(page, 'ensure_dismiss_keyboard'), + patch.object(page, 'input_ship_name') as input_name, + patch.object( + page, + '_click_ship_in_list', + return_value='U-47', + ) as click_ship, + patch.object(page, '_wait_leave_current_page'), + ): + assert page.change_single_ship(selector) == 'U-47' + + 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): + 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()) + 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=ShipType.BB, + ) as detect_ship_type, + patch('autowsgr.ui.choose_ship_page.time.sleep'), + ): + matched = page._click_ship_in_list( + 'U-96', + ship_type=(ShipType.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() 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..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.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" },