diff --git a/src/classes/action/assassinate.py b/src/classes/action/assassinate.py index 209e713..7072053 100644 --- a/src/classes/action/assassinate.py +++ b/src/classes/action/assassinate.py @@ -4,10 +4,10 @@ import random from src.classes.action import InstantAction from src.classes.action.cooldown import cooldown_action +from src.classes.action.targeting_mixin import TargetingMixin from src.classes.event import Event from src.classes.battle import decide_battle, get_assassination_success_rate from src.classes.story_teller import StoryTeller -from src.classes.normalize import normalize_avatar_name from src.classes.death import handle_death from src.classes.death_reason import DeathReason, DeathType from src.classes.kill_and_grab import kill_and_grab @@ -17,7 +17,7 @@ if TYPE_CHECKING: @cooldown_action -class Assassinate(InstantAction): +class Assassinate(InstantAction, TargetingMixin): ACTION_NAME = "暗杀" EMOJI = "🗡️" DESC = "暗杀目标,失败则变为战斗" @@ -39,15 +39,8 @@ class Assassinate(InstantAction): # 暗杀是大事(长期记忆) IS_MAJOR: bool = True - def _get_target(self, avatar_name: str) -> Avatar | None: - normalized_name = normalize_avatar_name(avatar_name) - for v in self.world.avatar_manager.avatars.values(): - if v.name == normalized_name: - return v - return None - def _execute(self, avatar_name: str) -> None: - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) if target is None: return @@ -77,13 +70,11 @@ class Assassinate(InstantAction): def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]: # 注意:cooldown_action 装饰器会覆盖这个方法并在调用此方法前检查 CD - if avatar_name is None: - return False, "缺少参数 avatar_name" - ok = self._get_target(avatar_name) is not None - return (ok, "" if ok else "目标不存在") + _, ok, reason = self.validate_target_avatar(avatar_name) + return ok, reason def start(self, avatar_name: str) -> Event: - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) target_name = target.name if target is not None else avatar_name event = Event(self.world.month_stamp, f"{self.avatar.name} 潜伏在阴影中,试图暗杀 {target_name}...", related_avatars=[self.avatar.id, target.id] if target else [self.avatar.id], is_major=True) @@ -91,7 +82,7 @@ class Assassinate(InstantAction): return event async def finish(self, avatar_name: str) -> list[Event]: - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) if target is None: return [] diff --git a/src/classes/action/attack.py b/src/classes/action/attack.py index c804637..6b892e2 100644 --- a/src/classes/action/attack.py +++ b/src/classes/action/attack.py @@ -2,15 +2,15 @@ from __future__ import annotations from typing import TYPE_CHECKING from src.classes.action import InstantAction +from src.classes.action.targeting_mixin import TargetingMixin from src.classes.event import Event from src.classes.battle import decide_battle, get_effective_strength_pair from src.classes.story_teller import StoryTeller -from src.classes.normalize import normalize_avatar_name from src.classes.death import handle_death from src.classes.death_reason import DeathReason from src.classes.kill_and_grab import kill_and_grab -class Attack(InstantAction): +class Attack(InstantAction, TargetingMixin): ACTION_NAME = "发起战斗" EMOJI = "⚔️" DESC = "攻击目标,进行对战" @@ -23,19 +23,8 @@ class Attack(InstantAction): # 战斗是大事(长期记忆) IS_MAJOR: bool = True - def _get_target(self, avatar_name: str): - """ - 根据名字查找目标角色;找不到返回 None。 - 会自动规范化名字(去除括号等附加信息)以提高容错性。 - """ - normalized_name = normalize_avatar_name(avatar_name) - for v in self.world.avatar_manager.avatars.values(): - if v.name == normalized_name: - return v - return None - def _execute(self, avatar_name: str) -> None: - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) if target is None: return winner, loser, loser_damage, winner_damage = decide_battle(self.avatar, target) @@ -53,13 +42,11 @@ class Attack(InstantAction): self._last_result = (winner, loser, loser_damage, winner_damage) def can_start(self, avatar_name: str | None = None) -> tuple[bool, str]: - if avatar_name is None: - return False, "缺少参数 avatar_name" - ok = self._get_target(avatar_name) is not None - return (ok, "" if ok else "目标不存在") + _, ok, reason = self.validate_target_avatar(avatar_name) + return ok, reason def start(self, avatar_name: str) -> Event: - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) target_name = target.name if target is not None else avatar_name # 展示双方折算战斗力(基于对手、含克制) s_att, s_def = get_effective_strength_pair(self.avatar, target) @@ -81,7 +68,7 @@ class Attack(InstantAction): if not (isinstance(res, tuple) and len(res) == 4): return [] - target = self._get_target(avatar_name) + target = self.find_avatar_by_name(avatar_name) start_text = getattr(self, '_start_event_content', "") from src.classes.battle import handle_battle_finish diff --git a/src/classes/action/targeting_mixin.py b/src/classes/action/targeting_mixin.py index f3d0525..cd2af2a 100644 --- a/src/classes/action/targeting_mixin.py +++ b/src/classes/action/targeting_mixin.py @@ -57,4 +57,26 @@ class TargetingMixin: avatar.load_decide_result_chain([(action_name, params)], avatar.thinking, "") avatar.commit_next_plan() + def validate_target_avatar(self, name: str | None) -> tuple["Avatar | None", bool, str]: + """ + 验证目标角色是否有效(存在且存活)。 + + Args: + name: 目标角色名。 + + Returns: + (target, can_proceed, reason) + - target: 找到的角色对象,无效时为 None。 + - can_proceed: 是否可以继续。 + - reason: 失败原因(成功时为空字符串)。 + """ + if not name: + return None, False, "缺少目标参数" + target = self.find_avatar_by_name(name) + if target is None: + return None, False, "目标不存在" + if target.is_dead: + return None, False, "目标已死亡" + return target, True, "" + diff --git a/src/classes/mutual_action/mutual_action.py b/src/classes/mutual_action/mutual_action.py index e2e605a..4a52d9d 100644 --- a/src/classes/mutual_action/mutual_action.py +++ b/src/classes/mutual_action/mutual_action.py @@ -140,6 +140,9 @@ class MutualAction(DefineAction, LLMAction, ActualActionMixin, TargetingMixin): """ 检查互动动作能否启动:目标需在发起者的交互范围内。 子类通过实现 _can_start 来添加额外检查。 + + 注意:此方法未使用 TargetingMixin.validate_target_avatar(), + 因为需要额外检查 target == self.avatar 和调用子类的 _can_start()。 """ if target_avatar is None: return False, "缺少参数 target_avatar" @@ -148,6 +151,8 @@ class MutualAction(DefineAction, LLMAction, ActualActionMixin, TargetingMixin): return False, "目标不存在" if target == self.avatar: return False, "不能对自己发起互动" + if target.is_dead: + return False, "目标已死亡" # 调用子类的额外检查 return self._can_start(target) diff --git a/tests/test_action_combat.py b/tests/test_action_combat.py index c581892..a985f40 100644 --- a/tests/test_action_combat.py +++ b/tests/test_action_combat.py @@ -2,6 +2,8 @@ import pytest from unittest.mock import MagicMock, patch, AsyncMock from src.classes.action.attack import Attack +from src.classes.action.assassinate import Assassinate +from src.classes.mutual_action.talk import Talk from src.classes.action_runtime import ActionStatus class TestActionCombat: @@ -70,8 +72,111 @@ class TestActionCombat: """测试目标不存在""" dummy_avatar.world.avatar_manager.avatars = {} action = Attack(dummy_avatar, dummy_avatar.world) - + ok, reason = action.can_start("Ghost") assert ok is False assert reason == "目标不存在" + def test_attack_can_start_dead_target(self, dummy_avatar, target_avatar): + """测试不能攻击已死亡的目标""" + target_avatar.is_dead = True + dummy_avatar.world.avatar_manager.avatars = {target_avatar.name: target_avatar} + + action = Attack(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start("TargetDummy") + + assert ok is False + assert reason == "目标已死亡" + + def test_attack_can_start_alive_target(self, dummy_avatar, target_avatar): + """测试可以攻击存活的目标""" + target_avatar.is_dead = False + dummy_avatar.world.avatar_manager.avatars = {target_avatar.name: target_avatar} + + action = Attack(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start("TargetDummy") + + assert ok is True + assert reason == "" + + +class TestAssassinate: + """暗杀动作测试""" + + @pytest.fixture + def target_avatar(self): + """创建一个靶子角色""" + target = MagicMock() + target.name = "TargetDummy" + target.id = "target_id" + target.is_dead = False + return target + + def test_assassinate_can_start_dead_target(self, dummy_avatar, target_avatar): + """测试不能暗杀已死亡的目标""" + target_avatar.is_dead = True + dummy_avatar.world.avatar_manager.avatars = {target_avatar.name: target_avatar} + + action = Assassinate(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start(avatar_name="TargetDummy") + + assert ok is False + assert reason == "目标已死亡" + + def test_assassinate_can_start_alive_target(self, dummy_avatar, target_avatar): + """测试可以暗杀存活的目标""" + target_avatar.is_dead = False + dummy_avatar.world.avatar_manager.avatars = {target_avatar.name: target_avatar} + + action = Assassinate(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start(avatar_name="TargetDummy") + + assert ok is True + assert reason == "" + + def test_assassinate_can_start_missing_target(self, dummy_avatar): + """测试目标不存在""" + dummy_avatar.world.avatar_manager.avatars = {} + + action = Assassinate(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start(avatar_name="Ghost") + + assert ok is False + assert reason == "目标不存在" + + +class TestMutualActionDeadTarget: + """互动动作对死亡目标的测试""" + + @pytest.fixture + def target_avatar(self): + """创建一个靶子角色""" + target = MagicMock() + target.name = "TargetDummy" + target.id = "target_id" + target.is_dead = False + target.tile = MagicMock() + return target + + def test_talk_can_start_dead_target(self, dummy_avatar, target_avatar): + """测试不能对已死亡的目标发起攀谈""" + target_avatar.is_dead = True + dummy_avatar.world.avatar_manager.avatars = {target_avatar.name: target_avatar} + + action = Talk(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start("TargetDummy") + + assert ok is False + assert reason == "目标已死亡" + + def test_talk_can_start_self(self, dummy_avatar): + """测试不能对自己发起攀谈""" + dummy_avatar.is_dead = False + dummy_avatar.world.avatar_manager.avatars = {dummy_avatar.name: dummy_avatar} + + action = Talk(dummy_avatar, dummy_avatar.world) + ok, reason = action.can_start(dummy_avatar.name) + + assert ok is False + assert reason == "不能对自己发起互动" +