diff --git a/src/classes/mutual_action/__init__.py b/src/classes/mutual_action/__init__.py index 2e6da2c..b179a15 100644 --- a/src/classes/mutual_action/__init__.py +++ b/src/classes/mutual_action/__init__.py @@ -7,7 +7,7 @@ from .conversation import Conversation from .dual_cultivation import DualCultivation from .talk import Talk from .impart import Impart -from .gift_spirit_stone import GiftSpiritStone +from .gift import Gift from .spar import Spar from .occupy import Occupy from src.classes.action.registry import register_action @@ -20,7 +20,7 @@ __all__ = [ "DualCultivation", "Talk", "Impart", - "GiftSpiritStone", + "Gift", "Spar", "Occupy", ] @@ -32,7 +32,7 @@ register_action(actual=True)(Conversation) register_action(actual=True)(DualCultivation) register_action(actual=True)(Talk) register_action(actual=True)(Impart) -register_action(actual=True)(GiftSpiritStone) +register_action(actual=True)(Gift) register_action(actual=True)(Spar) register_action(actual=True)(Occupy) diff --git a/src/classes/mutual_action/gift.py b/src/classes/mutual_action/gift.py new file mode 100644 index 0000000..070ba4a --- /dev/null +++ b/src/classes/mutual_action/gift.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .mutual_action import MutualAction +from src.classes.event import Event +from src.utils.config import CONFIG + +if TYPE_CHECKING: + from src.classes.avatar import Avatar + from src.classes.action_runtime import ActionResult + from src.classes.world import World + + +class Gift(MutualAction): + """赠送:向目标赠送灵石或物品。 + + - 支持赠送灵石、素材、装备。 + - 目标在交互范围内。 + - 目标可以感知具体赠送的物品并选择 接受 或 拒绝。 + - 若接受:物品从发起者转移给目标(装备会自动穿戴并顶替旧装备)。 + - 非灵石物品一次只能赠送1个。 + """ + + ACTION_NAME = "赠送" + EMOJI = "🎁" + DESC = "向对方赠送灵石或物品" + DOABLES_REQUIREMENTS = "发起者持有该物品;目标在交互范围内" + + PARAMS = { + "target_avatar": "Avatar", + "item_name": "str", + "amount": "int" + } + + FEEDBACK_ACTIONS = ["Accept", "Reject"] + + def __init__(self, avatar: "Avatar", world: "World"): + super().__init__(avatar, world) + # 暂存当前赠送上下文,用于 step 跨帧和 build_prompt_infos + self._current_gift_context: dict[str, Any] = {} + self._gift_success = False + + def _get_template_path(self) -> Path: + return CONFIG.paths.templates / "mutual_action.txt" + + def _resolve_gift(self, item_name: str, amount: int) -> tuple[Any, str, int]: + """ + 解析赠送意图,返回 (物品对象/None, 显示名称, 实际数量)。 + 物品对象为 None 代表是灵石。 + """ + # 1. 灵石 + if item_name == "灵石" or not item_name: + return None, "灵石", max(1, amount) + + # 非灵石强制数量为 1 + forced_amount = 1 + + # 2. 检查装备 (Weapon/Auxiliary) + if self.avatar.weapon and self.avatar.weapon.name == item_name: + return self.avatar.weapon, self.avatar.weapon.name, forced_amount + if self.avatar.auxiliary and self.avatar.auxiliary.name == item_name: + return self.avatar.auxiliary, self.avatar.auxiliary.name, forced_amount + + # 3. 检查背包素材 (Materials) + for mat, qty in self.avatar.materials.items(): + if mat.name == item_name: + return mat, mat.name, forced_amount + + # 未找到 + return None, "", 0 + + def _get_gift_description(self) -> str: + name = self._current_gift_context.get("name", "未知物品") + amount = self._current_gift_context.get("amount", 0) + obj = self._current_gift_context.get("obj") + + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + if obj is None: # 灵石 + return f"{amount} 灵石" + elif isinstance(obj, (Weapon, Auxiliary)): + return f"[{name}]" + else: + return f"{amount} {name}" + + def step(self, target_avatar: "Avatar|str", item_name: str = "灵石", amount: int = 100) -> ActionResult: + """ + 重写 step 以接收额外参数。 + 将参数存入 self,然后调用父类 step 执行通用逻辑(LLM交互)。 + """ + # 每一帧都会传入参数,更新上下文 + obj, name, real_amount = self._resolve_gift(item_name, amount) + + self._current_gift_context = { + "obj": obj, + "name": name, + "amount": real_amount, + "original_item_name": item_name + } + + # 调用父类 step,父类会调用 _build_prompt_infos -> _can_start 等 + return super().step(target_avatar) + + def _can_start(self, target: "Avatar") -> tuple[bool, str]: + """检查赠送条件:物品是否存在且足够""" + obj = self._current_gift_context.get("obj") + name = self._current_gift_context.get("name") + amount = self._current_gift_context.get("amount", 0) + original_name = self._current_gift_context.get("original_item_name") + + # 如果 name 为空,说明 resolve 失败 + if not name: + if original_name and original_name != "灵石": + return False, f"未找到物品:{original_name}" + # 如果是灵石但没解析出来(不应该发生,除非amount有问题,但max(1)了),或者是默认情况 + + # 1. 灵石 + if obj is None and name == "灵石": + if self.avatar.magic_stone < amount: + return False, f"灵石不足(当前:{self.avatar.magic_stone},需要:{amount})" + return True, "" + + # 2. 物品 (装备/素材) + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + if isinstance(obj, (Weapon, Auxiliary)): + if self.avatar.weapon is not obj and self.avatar.auxiliary is not obj: + return False, f"未装备该物品:{name}" + elif obj is not None: + # Material + qty = self.avatar.materials.get(obj, 0) + if qty < amount: + return False, f"物品不足:{name}" + else: + return False, f"未找到物品:{original_name}" + + # 检查交互范围 (父类 MutualAction.can_start 已经检查了,但这里是 _can_start 额外检查) + from src.classes.observe import is_within_observation + if not is_within_observation(self.avatar, target): + return False, "目标不在交互范围内" + + return True, "" + + def _build_prompt_infos(self, target_avatar: "Avatar") -> dict: + """ + 重写:构建传给 LLM 的 prompt 信息。 + """ + infos = super()._build_prompt_infos(target_avatar) + + gift_desc = self._get_gift_description() + infos["action_info"] = f"向你赠送 {gift_desc}" + + return infos + + def start(self, target_avatar: "Avatar|str", item_name: str = "灵石", amount: int = 100) -> Event: + # start 也会接收参数,同样需要设置上下文 + obj, name, real_amount = self._resolve_gift(item_name, amount) + self._current_gift_context = { + "obj": obj, + "name": name, + "amount": real_amount, + "original_item_name": item_name + } + + target = self._get_target_avatar(target_avatar) + target_name = target.name if target is not None else str(target_avatar) + + gift_desc = self._get_gift_description() + + rel_ids = [self.avatar.id] + if target is not None: + rel_ids.append(target.id) + + event = Event( + self.world.month_stamp, + f"{self.avatar.name} 试图向 {target_name} 赠送 {gift_desc}", + related_avatars=rel_ids + ) + + # 写入历史 + self.avatar.add_event(event, to_sidebar=False) + if target is not None: + target.add_event(event, to_sidebar=False) + + self._gift_success = False + return event + + def _settle_feedback(self, target_avatar: "Avatar", feedback_name: str) -> None: + fb = str(feedback_name).strip() + if fb == "Accept": + self._apply_gift(target_avatar) + self._gift_success = True + else: + self._gift_success = False + + def _apply_gift(self, target: "Avatar") -> None: + """执行物品转移""" + obj = self._current_gift_context.get("obj") + amount = self._current_gift_context.get("amount", 0) + + if obj is None: + # 灵石 + if self.avatar.magic_stone >= amount: + self.avatar.magic_stone -= amount + target.magic_stone += amount + else: + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + if isinstance(obj, (Weapon, Auxiliary)): + # 装备:发起者卸下 -> 目标装备(旧装备自动处理) + if self.avatar.weapon is obj: + self.avatar.weapon = None + elif self.avatar.auxiliary is obj: + self.avatar.auxiliary = None + else: + return # 已经不在身上了 + + # 目标装备 + new_equip = obj + + old_item = None + if isinstance(new_equip, Weapon): + old_item = target.weapon + target.weapon = new_equip + else: # Auxiliary + old_item = target.auxiliary + target.auxiliary = new_equip + + # 旧装备简单处理:折价变成灵石加给目标 + if old_item: + refund = int(getattr(old_item, "price", 0) * 0.5) + if refund > 0: + target.magic_stone += refund + + else: + # 素材:发起者移除 -> 目标添加 + if self.avatar.remove_material(obj, amount): + target.add_material(obj, amount) + + async def finish(self, target_avatar: "Avatar|str") -> list[Event]: + target = self._get_target_avatar(target_avatar) + events: list[Event] = [] + if target is None: + return events + + if self._gift_success: + gift_desc = self._get_gift_description() + result_text = f"{self.avatar.name} 成功赠送了 {gift_desc} 给 {target.name}" + + result_event = Event( + self.world.month_stamp, + result_text, + related_avatars=[self.avatar.id, target.id] + ) + events.append(result_event) + + return events diff --git a/src/classes/mutual_action/gift_spirit_stone.py b/src/classes/mutual_action/gift_spirit_stone.py deleted file mode 100644 index ac33d9e..0000000 --- a/src/classes/mutual_action/gift_spirit_stone.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from .mutual_action import MutualAction -from src.classes.event import Event -from src.utils.config import CONFIG - -if TYPE_CHECKING: - from src.classes.avatar import Avatar - - -class GiftSpiritStone(MutualAction): - """赠送灵石:向目标赠送灵石。 - - - 发起方灵石必须足够(至少100灵石) - - 目标在交互范围内 - - 目标可以选择 接受 或 拒绝 - - 若接受:发起方扣除100灵石,目标获得100灵石 - """ - - ACTION_NAME = "赠送灵石" - EMOJI = "🎁" - DESC = "向对方赠送灵石,一次赠送100灵石" - DOABLES_REQUIREMENTS = "发起者至少有100灵石;目标在交互范围内" - PARAMS = {"target_avatar": "AvatarName"} - FEEDBACK_ACTIONS = ["Accept", "Reject"] - - # 默认赠送数量 - GIFT_AMOUNT = 100 - - def _get_template_path(self) -> Path: - return CONFIG.paths.templates / "mutual_action.txt" - - def _can_start(self, target: "Avatar") -> tuple[bool, str]: - """检查赠送灵石的启动条件""" - from src.classes.observe import is_within_observation - if not is_within_observation(self.avatar, target): - return False, "目标不在交互范围内" - - # 检查发起者的灵石是否足够 - if self.avatar.magic_stone < self.GIFT_AMOUNT: - return False, f"灵石不足(当前:{self.avatar.magic_stone},需要:{self.GIFT_AMOUNT})" - - return True, "" - - def start(self, target_avatar: "Avatar|str") -> Event: - target = self._get_target_avatar(target_avatar) - target_name = target.name if target is not None else str(target_avatar) - rel_ids = [self.avatar.id] - if target is not None: - rel_ids.append(target.id) - event = Event( - self.world.month_stamp, - f"{self.avatar.name} 向 {target_name} 赠送 {self.GIFT_AMOUNT} 灵石", - related_avatars=rel_ids - ) - # 仅写入历史 - self.avatar.add_event(event, to_sidebar=False) - if target is not None: - target.add_event(event, to_sidebar=False) - # 初始化内部标记 - self._gift_success = False - return event - - def _settle_feedback(self, target_avatar: "Avatar", feedback_name: str) -> None: - fb = str(feedback_name).strip() - if fb == "Accept": - # 接受则当场结算灵石转移 - self._apply_gift(target_avatar) - self._gift_success = True - else: - # 拒绝 - self._gift_success = False - - def _apply_gift(self, target: "Avatar") -> None: - """执行灵石转移""" - # 从发起者扣除灵石 - self.avatar.magic_stone -= self.GIFT_AMOUNT - # 目标获得灵石 - target.magic_stone += self.GIFT_AMOUNT - - async def finish(self, target_avatar: "Avatar|str") -> list[Event]: - target = self._get_target_avatar(target_avatar) - events: list[Event] = [] - success = self._gift_success - if target is None: - return events - - if success: - result_text = f"""{self.avatar.name} 赠送了 {self.GIFT_AMOUNT} 灵石给 {target.name} -({self.avatar.name} 灵石:{self.avatar.magic_stone + self.GIFT_AMOUNT} → {self.avatar.magic_stone}, -{target.name} 灵石:{target.magic_stone - self.GIFT_AMOUNT} → {target.magic_stone})""" - result_event = Event( - self.world.month_stamp, - result_text, - related_avatars=[self.avatar.id, target.id] - ) - events.append(result_event) - return events - diff --git a/tests/test_action_gift.py b/tests/test_action_gift.py new file mode 100644 index 0000000..c878290 --- /dev/null +++ b/tests/test_action_gift.py @@ -0,0 +1,198 @@ +import pytest +import asyncio +from unittest.mock import patch, MagicMock +from src.classes.mutual_action.gift import Gift +from src.classes.action_runtime import ActionResult, ActionStatus +from src.classes.avatar import Avatar, Gender +from src.classes.age import Age +from src.classes.cultivation import Realm +from src.classes.relation import Relation +from src.utils.id_generator import get_avatar_id +from src.classes.calendar import create_month_stamp, Year, Month + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +@pytest.fixture +def target_avatar(base_world): + """创建第二个角色作为赠送目标""" + return Avatar( + world=base_world, + name="TargetNPC", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, + pos_y=0 + ) + +@pytest.fixture +def gift_action(dummy_avatar, base_world): + """初始化 Gift 动作""" + # 模拟 _call_llm_feedback,避免 step 中调用 asyncio.get_running_loop() + with patch.object(Gift, '_call_llm_feedback') as mock_llm: + # 返回一个 mock task,确保 task.done() 初始为 False, + # 但在这里我们主要是为了让 step 不报错 + mock_llm.return_value = {} + + action = Gift(dummy_avatar, base_world) + + # 我们直接 Mock 掉 step 里的异步创建任务逻辑, + # 因为我们主要测试的是: + # 1. 参数是否正确解析 (step -> _resolve_gift) + # 2. _can_start 逻辑 + # 3. 反馈结算逻辑 _settle_feedback / _apply_gift + + # 但 step 本身有逻辑: + # 1. 解析参数 + # 2. 检查 target + # 3. 创建 task (这里会报错) + + # 我们采用 patch asyncio.get_running_loop 的方式更简单 + yield action + +# ----------------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------------- + +class TestGiftAction: + + # --- 1. 赠送灵石 --- + + def test_gift_spirit_stone_success(self, gift_action, dummy_avatar, target_avatar): + """测试赠送灵石成功""" + dummy_avatar.magic_stone = 1000 + target_avatar.magic_stone = 0 + + # Mock asyncio loop just to pass the step() check + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name="灵石", amount=100) + + can_start, reason = gift_action._can_start(target_avatar) + assert can_start is True, f"Should be able to start: {reason}" + + # 模拟接受 + gift_action._settle_feedback(target_avatar, "Accept") + + assert dummy_avatar.magic_stone == 900 + assert target_avatar.magic_stone == 100 + assert gift_action._gift_success is True + + def test_gift_spirit_stone_insufficient(self, gift_action, dummy_avatar, target_avatar): + """测试灵石不足""" + dummy_avatar.magic_stone = 50 + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name="灵石", amount=100) + + can_start, reason = gift_action._can_start(target_avatar) + + assert can_start is False + assert "灵石不足" in reason + + # --- 2. 赠送素材 --- + + def test_gift_material_success(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """测试赠送素材成功""" + test_material = mock_item_data["obj_material"] + dummy_avatar.add_material(test_material, quantity=5) + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + # 非灵石强制数量 1 + gift_action.step(target_avatar, item_name=test_material.name, amount=999) + + can_start, reason = gift_action._can_start(target_avatar) + assert can_start is True + + gift_action._settle_feedback(target_avatar, "Accept") + + assert dummy_avatar.get_material_quantity(test_material) == 4 + assert target_avatar.get_material_quantity(test_material) == 1 + assert gift_action._current_gift_context["amount"] == 1 + + def test_gift_material_not_owned(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """测试赠送未持有的素材""" + test_material = mock_item_data["obj_material"] + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name=test_material.name, amount=1) + + can_start, reason = gift_action._can_start(target_avatar) + assert can_start is False + + # --- 3. 赠送装备 --- + + def test_gift_weapon_success_and_auto_equip(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """测试赠送装备成功,且目标自动装备""" + test_weapon = mock_item_data["obj_weapon"] + dummy_avatar.weapon = test_weapon + assert target_avatar.weapon is None + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name=test_weapon.name, amount=1) + + can_start, reason = gift_action._can_start(target_avatar) + assert can_start is True + + gift_action._settle_feedback(target_avatar, "Accept") + + assert dummy_avatar.weapon is None + assert target_avatar.weapon == test_weapon + + def test_gift_weapon_fail_not_equipped(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """测试赠送未装备的装备""" + test_weapon = mock_item_data["obj_weapon"] + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name=test_weapon.name, amount=1) + + can_start, reason = gift_action._can_start(target_avatar) + assert can_start is False + + def test_gift_weapon_target_trade_in(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """测试目标已有装备时,收到新装备会自动折价卖出旧的""" + from tests.conftest import create_test_weapon + from src.classes.cultivation import Realm + + new_weapon = mock_item_data["obj_weapon"] + dummy_avatar.weapon = new_weapon + + old_weapon = create_test_weapon("旧铁剑", Realm.Qi_Refinement, weapon_id=999) + old_weapon.price = 100 + target_avatar.weapon = old_weapon + target_avatar.magic_stone = 0 + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name=new_weapon.name, amount=1) + + gift_action._settle_feedback(target_avatar, "Accept") + + assert target_avatar.weapon == new_weapon + # 50% refund + assert target_avatar.magic_stone == 50 + + # --- 4. 上下文与描述 --- + + def test_prompt_info_description(self, gift_action, dummy_avatar, target_avatar, mock_item_data): + """验证传给 LLM 的 prompt 中包含具体物品描述""" + test_weapon = mock_item_data["obj_weapon"] + dummy_avatar.weapon = test_weapon + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name=test_weapon.name) + + infos = gift_action._build_prompt_infos(target_avatar) + + assert f"[{test_weapon.name}]" in infos["action_info"] + assert "赠送" in infos["action_info"] + + def test_prompt_info_description_stones(self, gift_action, dummy_avatar, target_avatar): + dummy_avatar.magic_stone = 1000 + + with patch("asyncio.get_running_loop", return_value=MagicMock()): + gift_action.step(target_avatar, item_name="灵石", amount=500) + + infos = gift_action._build_prompt_infos(target_avatar) + assert "500 灵石" in infos["action_info"]