From 6873746d29efdc706ea0d6b54b1967841101676e Mon Sep 17 00:00:00 2001 From: bridge Date: Mon, 5 Jan 2026 23:37:52 +0800 Subject: [PATCH] refactor buy and sell --- src/classes/action/__init__.py | 12 +- src/classes/action/buy.py | 69 +++++++---- src/classes/action/sell.py | 112 ++++++++++------- src/classes/normalize.py | 24 ++-- tests/test_buy_action.py | 16 +-- tests/test_sell_action.py | 215 +++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 90 deletions(-) create mode 100644 tests/test_sell_action.py diff --git a/src/classes/action/__init__.py b/src/classes/action/__init__.py index 868e773..e9c2e2c 100644 --- a/src/classes/action/__init__.py +++ b/src/classes/action/__init__.py @@ -25,7 +25,7 @@ from .breakthrough import Breakthrough from .play import Play from .hunt import Hunt from .harvest import Harvest -from .sell import SellItems +from .sell import Sell from .attack import Attack from .plunder_mortals import PlunderMortals from .help_mortals import HelpMortals @@ -37,7 +37,7 @@ from .switch_weapon import SwitchWeapon from .assassinate import Assassinate from .move_to_direction import MoveToDirection from .cast import Cast -from .buy import BuyItem +from .buy import Buy # 注册到 ActionRegistry(标注是否为实际可执行动作) register_action(actual=False)(Action) @@ -59,7 +59,7 @@ register_action(actual=True)(Breakthrough) register_action(actual=True)(Play) register_action(actual=True)(Hunt) register_action(actual=True)(Harvest) -register_action(actual=True)(SellItems) +register_action(actual=True)(Sell) register_action(actual=False)(Attack) register_action(actual=True)(PlunderMortals) register_action(actual=True)(HelpMortals) @@ -71,7 +71,7 @@ register_action(actual=True)(SwitchWeapon) register_action(actual=True)(Assassinate) register_action(actual=True)(MoveToDirection) register_action(actual=True)(Cast) -register_action(actual=True)(BuyItem) +register_action(actual=True)(Buy) # Talk 已移动到 mutual_action 模块,在那里注册 __all__ = [ @@ -96,7 +96,7 @@ __all__ = [ "Play", "Hunt", "Harvest", - "SellItems", + "Sell", "Attack", "PlunderMortals", "HelpMortals", @@ -108,7 +108,7 @@ __all__ = [ "Assassinate", "MoveToDirection", "Cast", - "BuyItem", + "Buy", # Talk 已移动到 mutual_action 模块 # Occupy 已移动到 mutual_action 模块 ] diff --git a/src/classes/action/buy.py b/src/classes/action/buy.py index b3995ae..a7b25a1 100644 --- a/src/classes/action/buy.py +++ b/src/classes/action/buy.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from typing import TYPE_CHECKING, Tuple, Any from src.classes.action import InstantAction @@ -7,62 +8,75 @@ from src.classes.event import Event from src.classes.region import CityRegion from src.classes.elixir import elixirs_by_name, Elixir from src.classes.item import items_by_name, Item +from src.classes.weapon import weapons_by_name, Weapon +from src.classes.auxiliary import auxiliaries_by_name, Auxiliary from src.classes.prices import prices -from src.classes.normalize import normalize_item_name +from src.classes.normalize import normalize_goods_name if TYPE_CHECKING: from src.classes.avatar import Avatar -class BuyItem(InstantAction): +class Buy(InstantAction): """ 在城镇购买物品。 如果是丹药:购买后强制立即服用。 如果是其他物品:购买后放入背包。 + 如果是装备(兵器/法宝):购买后直接装备(替换原有装备)。 """ - ACTION_NAME = "购买物品" + ACTION_NAME = "购买" EMOJI = "💸" - DESC = "在城镇购买物品(丹药购买后将立即服用)" + elixir_names_str = ", ".join(elixirs_by_name.keys()) + DESC = f"在城镇购买物品/装备(丹药购买后将立即服用)。可选丹药:{elixir_names_str}" DOABLES_REQUIREMENTS = "在城镇且金钱足够" - PARAMS = {"item_name": "str"} + PARAMS = {"target_name": "str"} - def _resolve_obj(self, item_name: str) -> Tuple[Any, str, str]: + def _resolve_obj(self, target_name: str) -> Tuple[Any, str, str]: """ 解析物品名称,返回 (对象, 类型, 显示名称)。 - 类型字符串: "elixir", "item", "unknown" + 类型字符串: "elixir", "item", "weapon", "auxiliary", "unknown" """ - normalized_name = normalize_item_name(item_name) + normalized_name = normalize_goods_name(target_name) # 1. 尝试作为丹药查找 if normalized_name in elixirs_by_name: # 这里的 elixirs_by_name 返回的是 list,我们取第一个作为购买对象 - # TODO: 如果未来有同名不同级的丹药,这里可能需要更精确的逻辑 elixir = elixirs_by_name[normalized_name][0] return elixir, "elixir", elixir.name - # 2. 尝试作为普通物品查找 + # 2. 尝试作为兵器查找 + weapon = weapons_by_name.get(normalized_name) + if weapon: + return weapon, "weapon", weapon.name + + # 3. 尝试作为辅助装备查找 + auxiliary = auxiliaries_by_name.get(normalized_name) + if auxiliary: + return auxiliary, "auxiliary", auxiliary.name + + # 4. 尝试作为普通物品查找 item = items_by_name.get(normalized_name) if item: return item, "item", item.name return None, "unknown", normalized_name - def can_start(self, item_name: str | None = None) -> tuple[bool, str]: + def can_start(self, target_name: str | None = None) -> tuple[bool, str]: region = self.avatar.tile.region if not isinstance(region, CityRegion): return False, "仅能在城市区域执行" - if item_name is None: + if target_name is None: # 用于动作空间检查 # 理论上只要有钱就可以买东西,这里简单判定金钱>0 ok = self.avatar.magic_stone > 0 return (ok, "" if ok else "身无分文") - obj, obj_type, display_name = self._resolve_obj(item_name) + obj, obj_type, display_name = self._resolve_obj(target_name) if obj_type == "unknown": - return False, f"未知物品: {item_name}" + return False, f"未知物品: {target_name}" # 检查价格 price = prices.get_buying_price(obj, self.avatar) @@ -85,8 +99,8 @@ class BuyItem(InstantAction): return True, "" - def _execute(self, item_name: str) -> None: - obj, obj_type, display_name = self._resolve_obj(item_name) + def _execute(self, target_name: str) -> None: + obj, obj_type, display_name = self._resolve_obj(target_name) if obj_type == "unknown": return @@ -98,11 +112,25 @@ class BuyItem(InstantAction): self.avatar.consume_elixir(obj) elif obj_type == "item": self.avatar.add_item(obj) + elif obj_type == "weapon": + # 购买装备需要深拷贝,因为装备有独立状态 + new_weapon = copy.deepcopy(obj) + self.avatar.change_weapon(new_weapon) + elif obj_type == "auxiliary": + # 购买装备需要深拷贝 + new_auxiliary = copy.deepcopy(obj) + self.avatar.change_auxiliary(new_auxiliary) - def start(self, item_name: str) -> Event: - obj, obj_type, display_name = self._resolve_obj(item_name) + def start(self, target_name: str) -> Event: + obj, obj_type, display_name = self._resolve_obj(target_name) - action_desc = "购买并服用了" if obj_type == "elixir" else "购买了" + if obj_type == "elixir": + action_desc = "购买并服用了" + elif obj_type in ["weapon", "auxiliary"]: + action_desc = "购买并装备了" + else: + action_desc = "购买了" + price = prices.get_buying_price(obj, self.avatar) if obj else 0 return Event( @@ -111,6 +139,5 @@ class BuyItem(InstantAction): related_avatars=[self.avatar.id] ) - async def finish(self, item_name: str) -> list[Event]: + async def finish(self, target_name: str) -> list[Event]: return [] - diff --git a/src/classes/action/sell.py b/src/classes/action/sell.py index 40867e5..e535fc9 100644 --- a/src/classes/action/sell.py +++ b/src/classes/action/sell.py @@ -1,70 +1,94 @@ from __future__ import annotations +from typing import Tuple, Any + from src.classes.action import InstantAction from src.classes.event import Event from src.classes.region import CityRegion from src.classes.item import items_by_name -from src.classes.normalize import normalize_item_name +from src.classes.normalize import normalize_goods_name -class SellItems(InstantAction): +class Sell(InstantAction): """ - 在城镇出售指定名称的物品,一次性卖出持有的全部数量。 - 收益通过 avatar.sell_item() 结算。 + 在城镇出售指定名称的物品/装备。 + 如果是材料:一次性卖出持有的全部数量。 + 如果是装备:卖出当前装备的(如果是当前装备)。 + 收益通过 avatar.sell_item() / sell_weapon() / sell_auxiliary() 结算。 """ - ACTION_NAME = "出售物品" + ACTION_NAME = "出售" EMOJI = "💰" - DESC = "在城镇出售持有的某类物品的全部" - DOABLES_REQUIREMENTS = "在城镇且背包非空" - PARAMS = {"item_name": "str"} + DESC = "在城镇出售持有的某类物品的全部,或当前装备" + DOABLES_REQUIREMENTS = "在城镇且持有可出售物品/装备" + PARAMS = {"target_name": "str"} - def _execute(self, item_name: str) -> None: + def _resolve_obj(self, target_name: str) -> Tuple[Any, str, str]: + """ + 解析出售对象 + 返回: (对象, 类型, 显示名称) + 类型: "item", "weapon", "auxiliary", "none" + """ + normalized_name = normalize_goods_name(target_name) + + # 1. 检查背包材料 + item = items_by_name.get(normalized_name) + if item and self.avatar.get_item_quantity(item) > 0: + return item, "item", item.name + + # 2. 检查当前兵器 + if self.avatar.weapon and normalize_goods_name(self.avatar.weapon.name) == normalized_name: + return self.avatar.weapon, "weapon", self.avatar.weapon.name + + # 3. 检查当前辅助装备 + if self.avatar.auxiliary and normalize_goods_name(self.avatar.auxiliary.name) == normalized_name: + return self.avatar.auxiliary, "auxiliary", self.avatar.auxiliary.name + + return None, "none", normalized_name + + def _execute(self, target_name: str) -> None: region = self.avatar.tile.region if not isinstance(region, CityRegion): return - # 规范化物品名称(去除境界等附加信息) - normalized_name = normalize_item_name(item_name) + obj, obj_type, _ = self._resolve_obj(target_name) - # 找到物品 - item = items_by_name.get(normalized_name) - if item is None: - return + if obj_type == "item": + quantity = self.avatar.get_item_quantity(obj) + self.avatar.sell_item(obj, quantity) + elif obj_type == "weapon": + self.avatar.sell_weapon(obj) + self.avatar.change_weapon(None) # 卖出后卸下 + elif obj_type == "auxiliary": + self.avatar.sell_auxiliary(obj) + self.avatar.change_auxiliary(None) # 卖出后卸下 - # 检查持有数量 - quantity = self.avatar.get_item_quantity(item) - if quantity <= 0: - return - - # 通过统一接口出售 - self.avatar.sell_item(item, quantity) - - def can_start(self, item_name: str | None = None) -> tuple[bool, str]: + def can_start(self, target_name: str | None = None) -> tuple[bool, str]: region = self.avatar.tile.region if not isinstance(region, CityRegion): return False, "仅能在城市区域执行" - if item_name is None: - # 用于动作空间:只要背包非空即可 - ok = bool(self.avatar.items) - return (ok, "" if ok else "背包为空,无可出售物品") + + if target_name is None: + # 用于动作空间:只要有任何可卖东西即可 + has_items = bool(self.avatar.items) + has_weapon = self.avatar.weapon is not None + has_auxiliary = self.avatar.auxiliary is not None + ok = has_items or has_weapon or has_auxiliary + return (ok, "" if ok else "背包为空且无装备,无可出售物品") - # 规范化物品名称 - normalized_name = normalize_item_name(item_name) - item = items_by_name.get(normalized_name) - if item is None: - return False, f"未知物品: {item_name}" - ok = self.avatar.get_item_quantity(item) > 0 - return (ok, "" if ok else "该物品数量为0") + obj, obj_type, _ = self._resolve_obj(target_name) + if obj_type == "none": + return False, f"未持有物品/装备: {target_name}" + + return True, "" - def start(self, item_name: str) -> Event: - # 规范化物品名称用于显示(与执行逻辑一致) - normalized_name = normalize_item_name(item_name) - # 尝试获取标准物品名(如果存在) - item = items_by_name.get(normalized_name) - display_name = item.name if item is not None else normalized_name - return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇出售 {display_name}", related_avatars=[self.avatar.id]) + def start(self, target_name: str) -> Event: + obj, obj_type, display_name = self._resolve_obj(target_name) + return Event( + self.world.month_stamp, + f"{self.avatar.name} 在城镇出售了 {display_name}", + related_avatars=[self.avatar.id] + ) - async def finish(self, item_name: str) -> list[Event]: + async def finish(self, target_name: str) -> list[Event]: return [] - diff --git a/src/classes/normalize.py b/src/classes/normalize.py index 84d7639..12bd5cc 100644 --- a/src/classes/normalize.py +++ b/src/classes/normalize.py @@ -105,28 +105,30 @@ def normalize_region_name(name: str) -> str: return s -def normalize_item_name(name: str) -> str: +def normalize_goods_name(name: str) -> str: """ - 规范化物品名称:去除境界标识等附加信息。 + 规范化商品名称(包括物品、兵器、法宝、丹药)。 - 处理格式: - - "青云鹿角 -(练气)" -> "青云鹿角" - - "风速马皮(筑基)" -> "风速马皮" + 统一逻辑: + 1. 移除括号及内容(如境界、类型说明) + 2. 移除尾部的 " -" 标记(常见于材料生成名) + 3. 移除首尾空格 Args: - name: 原始物品名称,可能包含境界等附加信息 + name: 原始商品名称 Returns: - 规范化后的物品名称 + 规范化后的商品名称 Examples: - >>> normalize_item_name("青云鹿角 -(练气)") + >>> normalize_goods_name("青云鹿角 -(练气)") # item '青云鹿角' - >>> normalize_item_name("风速马皮(筑基)") - '风速马皮' + >>> normalize_goods_name("精铁剑(练气)") # weapon + '精铁剑' + >>> normalize_goods_name("聚气丹(练气)") # elixir + '聚气丹' """ s = _remove_parentheses(name) - # 额外处理:去除尾部的 " -" 标记 s = s.rstrip(" -").strip() return s diff --git a/tests/test_buy_action.py b/tests/test_buy_action.py index 7e8d451..1839e34 100644 --- a/tests/test_buy_action.py +++ b/tests/test_buy_action.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import patch, MagicMock -from src.classes.action.buy import BuyItem +from src.classes.action.buy import Buy from src.classes.region import CityRegion, Region from src.classes.elixir import Elixir, ElixirType, ConsumedElixir from src.classes.item import Item @@ -76,7 +76,7 @@ def test_buy_item_success(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) # 1. 检查是否可购买 can_start, reason = action.can_start("铁矿石") @@ -100,7 +100,7 @@ def test_buy_elixir_success(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("聚气丹") assert can_start is True @@ -131,7 +131,7 @@ def test_buy_fail_not_in_city(dummy_avatar, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(dummy_avatar, dummy_avatar.world) + action = Buy(dummy_avatar, dummy_avatar.world) can_start, reason = action.can_start("铁矿石") assert can_start is False @@ -146,7 +146,7 @@ def test_buy_fail_no_money(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("铁矿石") assert can_start is False @@ -159,7 +159,7 @@ def test_buy_fail_unknown_item(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("不存在的东西") assert can_start is False @@ -179,7 +179,7 @@ def test_buy_elixir_fail_realm_too_low(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("筑基丹") assert can_start is False @@ -202,7 +202,7 @@ def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects): with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \ patch("src.classes.action.buy.items_by_name", items_mock): - action = BuyItem(avatar_in_city, avatar_in_city.world) + action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("聚气丹") assert can_start is False diff --git a/tests/test_sell_action.py b/tests/test_sell_action.py new file mode 100644 index 0000000..eeb28ba --- /dev/null +++ b/tests/test_sell_action.py @@ -0,0 +1,215 @@ +import pytest +from unittest.mock import patch, MagicMock +from src.classes.action.sell import Sell +from src.classes.region import CityRegion +from src.classes.item import Item +from src.classes.weapon import Weapon +from src.classes.auxiliary import Auxiliary +from src.classes.cultivation import Realm +from src.classes.tile import Tile, TileType +from src.classes.weapon_type import WeaponType + +# 创建测试用的对象 helper +def create_test_item(name, realm, item_id=101): + return Item( + id=item_id, + name=name, + desc="测试物品", + realm=realm + ) + +def create_test_weapon(name, realm, weapon_id=201): + return Weapon( + id=weapon_id, + name=name, + weapon_type=WeaponType.SWORD, + realm=realm, + desc="测试兵器", + effects={}, + effect_desc="" + ) + +def create_test_auxiliary(name, realm, aux_id=301): + return Auxiliary( + id=aux_id, + name=name, + realm=realm, + desc="测试法宝", + effects={}, + effect_desc="" + ) + +@pytest.fixture +def avatar_in_city(dummy_avatar): + """ + 修改 dummy_avatar,使其位于城市中,并给予初始状态 + """ + city_region = CityRegion(id=1, name="TestCity", desc="测试城市") + tile = Tile(0, 0, TileType.CITY) + tile.region = city_region + + dummy_avatar.tile = tile + dummy_avatar.magic_stone = 0 + dummy_avatar.items = {} + dummy_avatar.weapon = None + dummy_avatar.auxiliary = None + + return dummy_avatar + +@pytest.fixture +def mock_sell_objects(): + """ + Mock items_by_name 并提供测试对象 + """ + test_item = create_test_item("铁矿石", Realm.Qi_Refinement) + test_weapon = create_test_weapon("青云剑", Realm.Qi_Refinement) + test_auxiliary = create_test_auxiliary("聚灵珠", Realm.Qi_Refinement) + + items_mock = { + "铁矿石": test_item + } + + return items_mock, test_item, test_weapon, test_auxiliary + +def test_sell_item_success(avatar_in_city, mock_sell_objects): + """测试出售普通物品成功""" + items_mock, test_item, _, _ = mock_sell_objects + + # 给角色添加物品 + avatar_in_city.add_item(test_item, quantity=5) + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + + # 1. 检查是否可出售 + can_start, reason = action.can_start("铁矿石") + assert can_start is True + + # 2. 执行出售 + # 练气期物品基础价格 10,卖出倍率默认为 1.0 -> 单价 10 + # 卖出全部 5 个 -> 总价 50 + initial_money = avatar_in_city.magic_stone + expected_income = 50 + + action._execute("铁矿石") + + # 3. 验证结果 + assert avatar_in_city.magic_stone == initial_money + expected_income + assert avatar_in_city.get_item_quantity(test_item) == 0 + +def test_sell_weapon_success(avatar_in_city, mock_sell_objects): + """测试出售当前兵器成功""" + items_mock, _, test_weapon, _ = mock_sell_objects + + # 装备兵器 + avatar_in_city.weapon = test_weapon + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + + # 1. 检查是否可出售 + can_start, reason = action.can_start("青云剑") + assert can_start is True + + # 2. 执行出售 + # 练气期兵器基础价格 100,卖出倍率 1.0 -> 100 + # 注意:Prices.WEAPON_PRICES[Realm.Qi_Refinement] 实际值需确认,假设是 default 100 或 mock + # 根据 prices.py: WEAPON_PRICES = {Realm.Qi_Refinement: 10...} + # 等等,prices.py 里 Qi_Refinement 兵器是 10 吗? + # 让我们 check prices.py 的内容: + # Realm.Qi_Refinement: 10 (ITEM_PRICES) + # Realm.Qi_Refinement: 10 (WEAPON_PRICES) + # Realm.Qi_Refinement: 10 (AUXILIARY_PRICES) + # 看来练气期都是 10。 + + expected_income = 10 + + action._execute("青云剑") + + # 3. 验证结果 + assert avatar_in_city.magic_stone == expected_income + assert avatar_in_city.weapon is None + +def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects): + """测试出售当前法宝成功""" + items_mock, _, _, test_auxiliary = mock_sell_objects + + # 装备法宝 + avatar_in_city.auxiliary = test_auxiliary + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + + can_start, reason = action.can_start("聚灵珠") + assert can_start is True + + # 练气期辅助装备也是 10 + expected_income = 10 + + action._execute("聚灵珠") + + assert avatar_in_city.magic_stone == expected_income + assert avatar_in_city.auxiliary is None + +def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects): + """测试不在城市无法出售""" + items_mock, test_item, _, _ = mock_sell_objects + + # 确保不在城市 + assert not isinstance(dummy_avatar.tile.region, CityRegion) + dummy_avatar.add_item(test_item, 1) + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(dummy_avatar, dummy_avatar.world) + can_start, reason = action.can_start("铁矿石") + + assert can_start is False + assert "仅能在城市" in reason + +def test_sell_fail_no_item(avatar_in_city, mock_sell_objects): + """测试未持有该物品""" + items_mock, _, _, _ = mock_sell_objects + + # 背包为空,无装备 + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + can_start, reason = action.can_start("铁矿石") + + assert can_start is False + assert "未持有物品/装备" in reason + +def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects): + """测试未知物品名称""" + items_mock, _, _, _ = mock_sell_objects + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + can_start, reason = action.can_start("不存在的神器") + + assert can_start is False + assert "未持有物品/装备" in reason + +def test_sell_priority(avatar_in_city, mock_sell_objects): + """测试物品优先级:同名时优先卖背包里的材料""" + items_mock, test_item, test_weapon, _ = mock_sell_objects + + # 构造一个同名的兵器和材料(虽然逻辑上不太可能,但测试代码健壮性) + # 假设 items_mock 里有一个 "青云剑" 的材料 + fake_sword_item = create_test_item("青云剑", Realm.Qi_Refinement) + items_mock["青云剑"] = fake_sword_item + + # 角色同时拥有该材料和该兵器 + avatar_in_city.add_item(fake_sword_item, 1) + avatar_in_city.weapon = test_weapon # name也是 "青云剑" + + with patch("src.classes.action.sell.items_by_name", items_mock): + action = Sell(avatar_in_city, avatar_in_city.world) + + # 执行出售 + action._execute("青云剑") + + # 应该优先卖掉了材料 + assert avatar_in_city.get_item_quantity(fake_sword_item) == 0 + assert avatar_in_city.weapon is not None # 兵器还在 +