diff --git a/src/classes/action/__init__.py b/src/classes/action/__init__.py index 960cf11..6dfa348 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 .sold import SellItems +from .sell import SellItems from .attack import Attack from .plunder_mortals import PlunderMortals from .help_mortals import HelpMortals diff --git a/src/classes/action/sold.py b/src/classes/action/sell.py similarity index 76% rename from src/classes/action/sold.py rename to src/classes/action/sell.py index ec54f32..fbca4d7 100644 --- a/src/classes/action/sold.py +++ b/src/classes/action/sell.py @@ -4,14 +4,13 @@ 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.prices import prices from src.classes.normalize import normalize_item_name class SellItems(InstantAction): """ 在城镇出售指定名称的物品,一次性卖出持有的全部数量。 - 收益为 item_price * item_num,动作耗时1个月。 + 收益通过 avatar.sell_item() 结算。 """ ACTION_NAME = "出售物品" @@ -37,21 +36,8 @@ class SellItems(InstantAction): if quantity <= 0: return - # 计算价格并结算 - price_per = prices.get_price(item) - base_total_gain = price_per * quantity - - # 应用出售价格倍率加成 - price_multiplier_raw = self.avatar.effects.get("extra_item_sell_price_multiplier", 0.0) - price_multiplier = 1.0 + float(price_multiplier_raw or 0.0) - total_gain = int(base_total_gain * price_multiplier) - - # 扣除物品并增加灵石 - removed = self.avatar.remove_item(item, quantity) - if not removed: - return - - self.avatar.magic_stone = self.avatar.magic_stone + total_gain + # 通过统一接口出售 + self.avatar.sell_item(item, quantity) def can_start(self, item_name: str | None = None) -> tuple[bool, str]: region = self.avatar.tile.region @@ -78,9 +64,6 @@ class SellItems(InstantAction): 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]) - # InstantAction 已实现 step 完成 - async def finish(self, item_name: str) -> list[Event]: return [] - diff --git a/src/classes/avatar/info_presenter.py b/src/classes/avatar/info_presenter.py index aae12cc..4500335 100644 --- a/src/classes/avatar/info_presenter.py +++ b/src/classes/avatar/info_presenter.py @@ -28,8 +28,8 @@ def get_avatar_info(avatar: "Avatar", detailed: bool = False) -> dict: from src.classes.sect import get_sect_info_with_rank if detailed: - weapon_info = f"{avatar.weapon.get_detailed_info()},熟练度:{avatar.weapon_proficiency:.1f}%" - auxiliary_info = avatar.auxiliary.get_detailed_info() if avatar.auxiliary is not None else "无" + weapon_info = f"{avatar.weapon.get_detailed_info()},熟练度:{avatar.weapon_proficiency:.1f}%" if avatar.weapon else "无" + auxiliary_info = avatar.auxiliary.get_detailed_info() if avatar.auxiliary else "无" sect_info = get_sect_info_with_rank(avatar, detailed=True) alignment_info = avatar.alignment.get_detailed_info() if avatar.alignment is not None else "未知" region_info = region.get_detailed_info() if region is not None else "无" diff --git a/src/classes/avatar/inventory_mixin.py b/src/classes/avatar/inventory_mixin.py index 03a79dc..aad373a 100644 --- a/src/classes/avatar/inventory_mixin.py +++ b/src/classes/avatar/inventory_mixin.py @@ -104,3 +104,50 @@ class InventoryMixin: actual_amount = amount * gain_multiplier self.weapon_proficiency = min(100.0, self.weapon_proficiency + actual_amount) + # ==================== 出售接口 ==================== + + def _get_sell_multiplier(self: "Avatar") -> float: + """获取出售价格倍率(包含效果加成)""" + raw = self.effects.get("extra_item_sell_price_multiplier", 0.0) + return 1.0 + float(raw or 0.0) + + def sell_item(self: "Avatar", item: "Item", quantity: int = 1) -> int: + """ + 出售材料物品,返回获得的灵石数量。 + 应用 extra_item_sell_price_multiplier 效果。 + """ + from src.classes.prices import prices + + if quantity <= 0 or self.get_item_quantity(item) < quantity: + return 0 + + self.remove_item(item, quantity) + + base_price = prices.get_item_price(item) * quantity + total = int(base_price * self._get_sell_multiplier()) + + self.magic_stone = self.magic_stone + total + return total + + def sell_weapon(self: "Avatar", weapon: "Weapon") -> int: + """ + 出售兵器,返回获得的灵石数量。 + 注意:这是辅助方法,不会自动卸下当前装备。 + """ + from src.classes.prices import prices + + total = int(prices.get_weapon_price(weapon) * self._get_sell_multiplier()) + self.magic_stone = self.magic_stone + total + return total + + def sell_auxiliary(self: "Avatar", auxiliary: "Auxiliary") -> int: + """ + 出售辅助装备,返回获得的灵石数量。 + 注意:这是辅助方法,不会自动卸下当前装备。 + """ + from src.classes.prices import prices + + total = int(prices.get_auxiliary_price(auxiliary) * self._get_sell_multiplier()) + self.magic_stone = self.magic_stone + total + return total + diff --git a/src/classes/fortune.py b/src/classes/fortune.py index c6aa261..018c906 100644 --- a/src/classes/fortune.py +++ b/src/classes/fortune.py @@ -423,7 +423,7 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: }, { "key": "B", - "desc": f"放弃原{type_label},接受新{type_label}『{new_name}』({new_grade_val})。" + "desc": f"卖掉原{type_label}『{old_name}』换取灵石,接受新{type_label}『{new_name}』({new_grade_val})。" } ] @@ -435,7 +435,7 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: if choice == "A": return False, f"{avatar.name} 放弃了{new_grade_val}{type_label}『{new_name}』,保留了『{old_name}』" else: - return True, f"{avatar.name} 获得了{new_grade_val}{type_label}『{new_name}』,替换了『{old_name}』" + return True, f"{avatar.name} 获得了{new_grade_val}{type_label}『{new_name}』,卖掉了『{old_name}』" if kind == FortuneKind.WEAPON: weapon = _get_weapon_for_avatar(avatar) @@ -448,6 +448,9 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: weapon, avatar.weapon, "兵器" ) if should_equip: + # 自动卖掉旧武器 + if avatar.weapon is not None: + avatar.sell_weapon(avatar.weapon) avatar.change_weapon(weapon) if kind == FortuneKind.AUXILIARY: @@ -461,6 +464,9 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: auxiliary, avatar.auxiliary, "辅助装备" ) if should_equip: + # 自动卖掉旧辅助装备 + if avatar.auxiliary is not None: + avatar.sell_auxiliary(avatar.auxiliary) avatar.change_auxiliary(auxiliary) if kind == FortuneKind.TECHNIQUE: diff --git a/src/classes/kill_and_grab.py b/src/classes/kill_and_grab.py index bf4a952..7b762cd 100644 --- a/src/classes/kill_and_grab.py +++ b/src/classes/kill_and_grab.py @@ -56,7 +56,7 @@ async def kill_and_grab(winner: Avatar, loser: Avatar) -> str: options = [ { "key": "A", - "desc": f"夺取『{loot_item.name}』,替换掉身上的『{winner_current.name}』。\n - 新装备:{item_desc}\n - 原装备:{current_desc}" + "desc": f"夺取『{loot_item.name}』,卖掉身上的『{winner_current.name}』换取灵石。\n - 新装备:{item_desc}\n - 原装备:{current_desc}" }, { "key": "B", @@ -69,9 +69,15 @@ async def kill_and_grab(winner: Avatar, loser: Avatar) -> str: if should_loot: if loot_type == "weapon": + # 自动卖掉旧武器 + if winner.weapon is not None: + winner.sell_weapon(winner.weapon) winner.change_weapon(loot_item) loser.change_weapon(None) else: + # 自动卖掉旧辅助装备 + if winner.auxiliary is not None: + winner.sell_auxiliary(winner.auxiliary) winner.change_auxiliary(loot_item) loser.change_auxiliary(None) diff --git a/src/classes/prices.py b/src/classes/prices.py index 0e0e0ac..53abf2f 100644 --- a/src/classes/prices.py +++ b/src/classes/prices.py @@ -1,23 +1,89 @@ +""" +统一价格系统 +============ + +所有物品/装备的价格通过这个模块获取。 +价格只和对应的 realm 绑定,全局统一。 + +价格设计参考(以练气期年收入约 20-30 灵石为基准): +- 材料(Item): 采集物等消耗品 +- 兵器(Weapon): 稀有装备,价值较高 +- 辅助装备(Auxiliary): 法宝等,价值次于兵器 +""" +from __future__ import annotations + +from typing import Union, TYPE_CHECKING + from src.classes.cultivation import Realm -from src.classes.item import Item + +if TYPE_CHECKING: + from src.classes.item import Item + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + +# 类型别名 +Sellable = Union["Item", "Weapon", "Auxiliary"] + class Prices: """ 价格体系。 - 刚开始我只准备做一个比较简单的价格体系,之后可能复杂化。 - 目前是所有的城镇都可以出售材料,同时这些材料的价格是固定的,并且全局公开。 - 价格只和对应的realm绑定。 + 所有城镇可交易物品/装备的价格在此统一管理。 """ - def __init__(self): - self.realm_to_prices = { - Realm.Qi_Refinement: 10, - Realm.Foundation_Establishment: 50, - Realm.Core_Formation: 100, - Realm.Nascent_Soul: 200, - } + + # 材料价格表(采集物等) + ITEM_PRICES = { + Realm.Qi_Refinement: 10, + Realm.Foundation_Establishment: 30, + Realm.Core_Formation: 60, + Realm.Nascent_Soul: 100, + } + + # 兵器价格表(稀有,价值高) + WEAPON_PRICES = { + Realm.Qi_Refinement: 10, + Realm.Foundation_Establishment: 300, + Realm.Core_Formation: 1000, + Realm.Nascent_Soul: 2000, + } + + # 辅助装备价格表 + AUXILIARY_PRICES = { + Realm.Qi_Refinement: 10, + Realm.Foundation_Establishment: 250, + Realm.Core_Formation: 800, + Realm.Nascent_Soul: 1600, + } + + def get_item_price(self, item: "Item") -> int: + """获取材料价格""" + return self.ITEM_PRICES.get(item.realm, 10) + + def get_weapon_price(self, weapon: "Weapon") -> int: + """获取兵器价格""" + return self.WEAPON_PRICES.get(weapon.realm, 100) + + def get_auxiliary_price(self, auxiliary: "Auxiliary") -> int: + """获取辅助装备价格""" + return self.AUXILIARY_PRICES.get(auxiliary.realm, 80) + + def get_price(self, obj: Sellable) -> int: + """ + 统一价格查询接口。 + 根据对象类型自动分发到对应的价格查询方法。 + """ + from src.classes.item import Item + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + if isinstance(obj, Item): + return self.get_item_price(obj) + elif isinstance(obj, Weapon): + return self.get_weapon_price(obj) + elif isinstance(obj, Auxiliary): + return self.get_auxiliary_price(obj) + return 0 - def get_price(self, item: Item) -> int: - return self.realm_to_prices[item.realm] -# 预先创建全局价格实例,供全局使用 -prices = Prices() \ No newline at end of file +# 全局单例 +prices = Prices() diff --git a/tests/test_prices.py b/tests/test_prices.py new file mode 100644 index 0000000..d28d888 --- /dev/null +++ b/tests/test_prices.py @@ -0,0 +1,178 @@ +import pytest +import copy +from unittest.mock import MagicMock, patch + +from src.classes.prices import prices, Prices +from src.classes.cultivation import Realm +from src.classes.item import items_by_id +from src.classes.weapon import weapons_by_id, Weapon, get_random_weapon_by_realm +from src.classes.auxiliary import auxiliaries_by_id, Auxiliary, get_random_auxiliary_by_realm + + +class TestPrices: + """价格系统测试""" + + def test_item_prices_by_realm(self): + """测试材料价格按境界递增""" + assert prices.ITEM_PRICES[Realm.Qi_Refinement] < prices.ITEM_PRICES[Realm.Foundation_Establishment] + assert prices.ITEM_PRICES[Realm.Foundation_Establishment] < prices.ITEM_PRICES[Realm.Core_Formation] + assert prices.ITEM_PRICES[Realm.Core_Formation] < prices.ITEM_PRICES[Realm.Nascent_Soul] + + def test_weapon_prices_by_realm(self): + """测试兵器价格按境界递增""" + assert prices.WEAPON_PRICES[Realm.Qi_Refinement] < prices.WEAPON_PRICES[Realm.Foundation_Establishment] + assert prices.WEAPON_PRICES[Realm.Foundation_Establishment] < prices.WEAPON_PRICES[Realm.Core_Formation] + assert prices.WEAPON_PRICES[Realm.Core_Formation] < prices.WEAPON_PRICES[Realm.Nascent_Soul] + + def test_auxiliary_prices_by_realm(self): + """测试辅助装备价格按境界递增""" + assert prices.AUXILIARY_PRICES[Realm.Qi_Refinement] < prices.AUXILIARY_PRICES[Realm.Foundation_Establishment] + assert prices.AUXILIARY_PRICES[Realm.Foundation_Establishment] < prices.AUXILIARY_PRICES[Realm.Core_Formation] + assert prices.AUXILIARY_PRICES[Realm.Core_Formation] < prices.AUXILIARY_PRICES[Realm.Nascent_Soul] + + def test_get_price_for_item(self): + """测试 get_price 对 Item 类型的分发""" + if not items_by_id: + pytest.skip("No items available in config") + + item = next(iter(items_by_id.values())) + price = prices.get_price(item) + expected = prices.get_item_price(item) + assert price == expected + assert price == prices.ITEM_PRICES[item.realm] + + def test_get_price_for_weapon(self): + """测试 get_price 对 Weapon 类型的分发""" + if not weapons_by_id: + pytest.skip("No weapons available in config") + + weapon = next(iter(weapons_by_id.values())) + price = prices.get_price(weapon) + expected = prices.get_weapon_price(weapon) + assert price == expected + assert price == prices.WEAPON_PRICES[weapon.realm] + + def test_get_price_for_auxiliary(self): + """测试 get_price 对 Auxiliary 类型的分发""" + if not auxiliaries_by_id: + pytest.skip("No auxiliaries available in config") + + aux = next(iter(auxiliaries_by_id.values())) + price = prices.get_price(aux) + expected = prices.get_auxiliary_price(aux) + assert price == expected + assert price == prices.AUXILIARY_PRICES[aux.realm] + + def test_weapon_more_expensive_than_item(self): + """测试同境界下兵器比材料贵""" + for realm in Realm: + if realm in prices.ITEM_PRICES and realm in prices.WEAPON_PRICES: + assert prices.WEAPON_PRICES[realm] >= prices.ITEM_PRICES[realm] + + +class TestAvatarSell: + """Avatar 出售接口测试""" + + def test_sell_item_basic(self, dummy_avatar): + """测试基础材料出售""" + if not items_by_id: + pytest.skip("No items available in config") + + item = next(iter(items_by_id.values())) + dummy_avatar.items = {} # 清空背包 + dummy_avatar.magic_stone.value = 0 + + # 添加物品 + dummy_avatar.add_item(item, 5) + assert dummy_avatar.get_item_quantity(item) == 5 + + # 出售3个 + gained = dummy_avatar.sell_item(item, 3) + + expected_price = prices.get_item_price(item) * 3 + assert gained == expected_price + assert dummy_avatar.magic_stone.value == expected_price + assert dummy_avatar.get_item_quantity(item) == 2 + + def test_sell_item_insufficient(self, dummy_avatar): + """测试出售物品数量不足""" + if not items_by_id: + pytest.skip("No items available in config") + + item = next(iter(items_by_id.values())) + dummy_avatar.items = {} + dummy_avatar.magic_stone.value = 100 + + dummy_avatar.add_item(item, 2) + + # 尝试出售5个(只有2个) + gained = dummy_avatar.sell_item(item, 5) + + assert gained == 0 + assert dummy_avatar.magic_stone.value == 100 # 没有变化 + assert dummy_avatar.get_item_quantity(item) == 2 # 物品未减少 + + def test_sell_weapon(self, dummy_avatar): + """测试出售兵器""" + weapon = get_random_weapon_by_realm(Realm.Foundation_Establishment) + if not weapon: + pytest.skip("No Foundation Establishment weapons available") + + dummy_avatar.magic_stone.value = 0 + + gained = dummy_avatar.sell_weapon(weapon) + + expected = prices.get_weapon_price(weapon) + assert gained == expected + assert dummy_avatar.magic_stone.value == expected + + def test_sell_auxiliary(self, dummy_avatar): + """测试出售辅助装备""" + aux = get_random_auxiliary_by_realm(Realm.Core_Formation) + if not aux: + pytest.skip("No Core Formation auxiliaries available") + + dummy_avatar.magic_stone.value = 0 + + gained = dummy_avatar.sell_auxiliary(aux) + + expected = prices.get_auxiliary_price(aux) + assert gained == expected + assert dummy_avatar.magic_stone.value == expected + + def test_sell_with_price_multiplier(self, dummy_avatar): + """测试出售价格倍率效果""" + if not items_by_id: + pytest.skip("No items available in config") + + item = next(iter(items_by_id.values())) + dummy_avatar.items = {} + dummy_avatar.magic_stone.value = 0 + dummy_avatar.add_item(item, 1) + + base_price = prices.get_item_price(item) + + # 设置 20% 加成 - patch 内部方法 + with patch.object(dummy_avatar, '_get_sell_multiplier', return_value=1.2): + gained = dummy_avatar.sell_item(item, 1) + + expected = int(base_price * 1.2) + assert gained == expected + assert dummy_avatar.magic_stone.value == expected + + def test_sell_weapon_with_multiplier(self, dummy_avatar): + """测试出售兵器时价格倍率生效""" + weapon = get_random_weapon_by_realm(Realm.Qi_Refinement) + if not weapon: + pytest.skip("No Qi Refinement weapons available") + + dummy_avatar.magic_stone.value = 0 + base_price = prices.get_weapon_price(weapon) + + # 设置 50% 加成 - patch 内部方法 + with patch.object(dummy_avatar, '_get_sell_multiplier', return_value=1.5): + gained = dummy_avatar.sell_weapon(weapon) + + expected = int(base_price * 1.5) + assert gained == expected +