fix: prevent actions on dead avatars

- Add validate_target_avatar() to TargetingMixin for unified validation.
- Update Attack and Assassinate to use the new validation method.
- Add comment to MutualAction.can_start() explaining why it uses inline check.
- Add tests for dead target validation.
This commit is contained in:
Zihao Xu
2026-01-04 19:29:35 -08:00
parent 8727a4f29a
commit 8d985e0a2b
5 changed files with 147 additions and 37 deletions

View File

@@ -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 []

View File

@@ -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

View File

@@ -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, ""

View File

@@ -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)

View File

@@ -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 == "不能对自己发起互动"