refactor gift

This commit is contained in:
bridge
2026-01-11 20:33:54 +08:00
parent 2056538375
commit 3a0e432b02
4 changed files with 463 additions and 105 deletions

View File

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

View File

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

View File

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

198
tests/test_action_gift.py Normal file
View File

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